Skip to content

Reference

cocorum

An unofficial Python wrapper for the Rumble.com APIs

A Python wrapper for the Rumble Live Stream API v1.0 (beta), with some quality of life additions, such as: - Automatic refresh when past the refresh_rate delay when querying any non_static property. - All timespamps are parsed to seconds since Epoch, UTC timezone. - Chat has new_messages and new_rants properties that return only messages and rants since the last time they were read.

Modules exported by this package:

  • chatapi: Provide the ChatAPI object for interacting with a livestream chat.
  • servicephp: Privide the ServicePHP object for interacting with the service.php API.
  • uploadphp: Provide the UploadPHP object for uploading videos.
  • scraping: Provide functions and the Scraper object for getting various data via HTML scraping.
  • jsonhandles: Abstract classes for handling JSON data blocks.
  • utils: Various utility functions for internal calculations and checks.
  • static: Global data that does not change across the package.

Most attributes that are not added features have the same name as the direct JSON counterparts, with the exception of adding prefixes to some things that have the same name in the JSON as Python builtin functions. For example, thing/id in JSON is thing.thing_id in this Python wrapper.

Example usage:

from cocorum import RumbleAPI

api = RumbleAPI(API_URL, refresh_rate = 10)

print(api.username)
print(api.latest_follower)

if api.latest_subscriber:
    print(api.latest_subscriber, "subscribed for $" + str(api.latest_subscriber.amount_dollars))

#RumbleLivestream objects returned by RumbleAPI properties are deep: When queried, they will pull new information via their parent RumbleAPI object.
livestream = api.latest_livestream #None if there is no stream running

if livestream:
    if livestream.visibility != "public":
        print("Stream is not public.")

    #Get messages for one minute
    start_time = time.time()
    while time.time() - start_time < 60 and livestream.is_live:
        for message in livestream.chat.new_messages:
            print(message.username, "said", message)
        time.sleep(0.1)

S.D.G.

ChatMessage

Bases: JSONUserAction

A single message in a Rumble livestream chat

Source code in cocorum/__init__.py
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
class ChatMessage(JSONUserAction):
    """A single message in a Rumble livestream chat"""
    def __eq__(self, other):
        """Is this message equal to another?

    Args:
        other (str, ChatMessage): Object to compare to.

    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """

        #Check if the compared string is our message
        if isinstance(other, str):
            return self.text == other

        # #Check if the compared message has the same username and text (not needed)
        # if type(other) == type(self):
        #     return (self.username, self.text) == (other.username, other.text)

        #Check if the compared object has the same text
        if hasattr(other, "text"):
            #Check if the compared object has the same username, if it has one
            if hasattr(other, "username"):
                return (self.username, self.text) == (other.username, other.text)

            return self.text == other.text #the other object had no username attribute

    def __str__(self):
        """Message as a string (its content)"""
        return self.text

    @property
    def text(self):
        """The message text"""
        return self["text"]

    @property
    def created_on(self):
        """When the message was created, in seconds since Epoch UTC"""
        return utils.parse_timestamp(self["created_on"])

    @property
    def badges(self):
        """The user's badges"""
        return tuple(self["badges"].values())

badges property

The user's badges

created_on property

When the message was created, in seconds since Epoch UTC

text property

The message text

__eq__(other)

Is this message equal to another?

Parameters:

Name Type Description Default
other (str, ChatMessage)

Object to compare to.

required

Returns:

Name Type Description
Comparison (bool, None)

Did it fit the criteria?

Source code in cocorum/__init__.py
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
def __eq__(self, other):
    """Is this message equal to another?

Args:
    other (str, ChatMessage): Object to compare to.

Returns:
    Comparison (bool, None): Did it fit the criteria?
    """

    #Check if the compared string is our message
    if isinstance(other, str):
        return self.text == other

    # #Check if the compared message has the same username and text (not needed)
    # if type(other) == type(self):
    #     return (self.username, self.text) == (other.username, other.text)

    #Check if the compared object has the same text
    if hasattr(other, "text"):
        #Check if the compared object has the same username, if it has one
        if hasattr(other, "username"):
            return (self.username, self.text) == (other.username, other.text)

        return self.text == other.text #the other object had no username attribute

__str__()

Message as a string (its content)

Source code in cocorum/__init__.py
308
309
310
def __str__(self):
    """Message as a string (its content)"""
    return self.text

Follower

Bases: JSONUserAction

Rumble follower

Source code in cocorum/__init__.py
59
60
61
62
63
64
class Follower(JSONUserAction):
    """Rumble follower"""
    @property
    def followed_on(self):
        """When the follower followed, in seconds since Epoch UTC"""
        return utils.parse_timestamp(self["followed_on"])

followed_on property

When the follower followed, in seconds since Epoch UTC

LiveChat

Reference for chat of a Rumble livestream

Source code in cocorum/__init__.py
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
class LiveChat():
    """Reference for chat of a Rumble livestream"""
    def __init__(self, stream):
        """Reference for chat of a Rumble livestream

    Args:
        stream (dict): The JSON block of a single Rumble livestream.
        """

        self.stream = stream
        self.api = stream.api
        self.last_newmessage_time = 0 #Last time we were checked for "new" messages
        self.last_newrant_time = 0 #Last time we were checked for "new" rants

    def __getitem__(self, key):
        """Return a key from the stream's chat JSON"""
        return self.stream["chat"][key]

    @property
    def latest_message(self):
        """The latest chat message"""
        if not self["latest_message"]:
            return None #No-one has chatted on this stream yet
        return ChatMessage(self["latest_message"])

    @property
    def recent_messages(self):
        """Recent chat messages"""
        data = self["recent_messages"].copy()
        return [ChatMessage(jsondata_block) for jsondata_block in data]

    @property
    def new_messages(self):
        """Chat messages that are newer than the last time this was referenced"""
        rem = self.recent_messages.copy()
        rem.sort(key = lambda x: x.created_on) #Sort the messages so the newest ones are last

        #There are no recent messages, or all messages are older than the last time we checked
        if not rem or rem[-1].created_on < self.last_newmessage_time:
            return []

        i = 0
        for i, m in enumerate(rem):
            if m.created_on > self.last_newmessage_time:
                break #i is now the index of the oldest new message

        self.last_newmessage_time = time.time()
        return rem[i:]

    @property
    def latest_rant(self):
        """The latest chat rant"""
        if not self["latest_rant"]:
            return None #No-one has ranted on this stream yet
        return Rant(self["latest_rant"])

    @property
    def recent_rants(self):
        """Recent chat rants"""
        data = self["recent_rants"].copy()
        return [Rant(jsondata_block) for jsondata_block in data]

    @property
    def new_rants(self):
        """Chat rants that are newer than the last time this was referenced"""
        rera = self.recent_rants.copy()
        rera.sort(key = lambda x: x.created_on) #Sort the rants so the newest ones are last

        #There are no recent rants, or all rants are older than the last time we checked
        if not rera or rera[-1].created_on < self.last_newrant_time:
            return []

        i = 0
        for i, r in enumerate(rera):
            if r.created_on > self.last_newrant_time:
                break #i is now the index of the oldest new rant

        self.last_newrant_time = time.time()
        return rera[i:]

latest_message property

The latest chat message

latest_rant property

The latest chat rant

new_messages property

Chat messages that are newer than the last time this was referenced

new_rants property

Chat rants that are newer than the last time this was referenced

recent_messages property

Recent chat messages

recent_rants property

Recent chat rants

__getitem__(key)

Return a key from the stream's chat JSON

Source code in cocorum/__init__.py
390
391
392
def __getitem__(self, key):
    """Return a key from the stream's chat JSON"""
    return self.stream["chat"][key]

__init__(stream)

Reference for chat of a Rumble livestream

Parameters:

Name Type Description Default
stream dict

The JSON block of a single Rumble livestream.

required
Source code in cocorum/__init__.py
378
379
380
381
382
383
384
385
386
387
388
def __init__(self, stream):
    """Reference for chat of a Rumble livestream

Args:
    stream (dict): The JSON block of a single Rumble livestream.
    """

    self.stream = stream
    self.api = stream.api
    self.last_newmessage_time = 0 #Last time we were checked for "new" messages
    self.last_newrant_time = 0 #Last time we were checked for "new" rants

Livestream

Rumble livestream

Source code in cocorum/__init__.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
class Livestream():
    """Rumble livestream"""
    def __init__(self, jsondata, api):
        """Rumble livestream

    Args:
        jsondata (dict): The JSON block for a single livestream.
        api (RumbleAPI): The Rumble Live Stream API wrapper that spawned us.
        """

        self._jsondata = jsondata
        self.api = api
        self.is_disappeared = False #The livestream is in the API listing
        self.__chat = LiveChat(self)

    def __eq__(self, other):
        """Is this stream equal to another?

    Args:
        other (str, int, Livestream): Object to compare to.

    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """

        #Check if the compared string is our stream ID
        if isinstance(other, str):
            return self.stream_id == other #or self.title == other

        #check if the compared number is our chat ID (linked to stream ID)
        if isinstance(other, (int, float)):
            return self.stream_id_b10 == other

        #Check if the compared object has the same stream ID
        if hasattr(other, "stream_id"):
            return self.stream_id == utils.ensure_b36(other.stream_id)

        #Check if the compared object has the same chat ID
        if hasattr(other, "stream_id_b10"):
            return self.stream_id_b10 == other.stream_id_b10

    def __str__(self):
        """The livestream in string form (it's ID in base 36)"""
        return self.stream_id

    def __getitem__(self, key):
        """Return a key from the JSON, refreshing if necessary

    Args:
        key (str): A valid JSON key.
        """

        #The livestream has not disappeared from the API listing,
        #the key requested is not a value that doesn't change,
        #and it has been api.refresh rate since the last time we refreshed
        if (not self.is_disappeared) and (key not in static.StaticAPIEndpoints.main) and (time.time() - self.api.last_refresh_time > self.api.refresh_rate):
            self.api.refresh()

        return self._jsondata[key]

    @property
    def stream_id(self):
        """The livestream ID in base 36"""
        return self["id"]

    @property
    def stream_id_b36(self):
        """The livestream ID in base 36"""
        return self.stream_id

    @property
    def stream_id_b10(self):
        """The livestream chat ID (stream ID in base 10)"""
        return utils.base_36_to_10(self.stream_id)

    @property
    def title(self):
        """The title of the livestream"""
        return self["title"]

    @property
    def created_on(self):
        """When the livestream was created, in seconds since the Epock UTC"""
        return utils.parse_timestamp(self["created_on"])

    @property
    def is_live(self):
        """Is the stream live?"""
        return self["is_live"] and not self.is_disappeared

    @property
    def visibility(self):
        """Is the stream public, unlisted, or private?"""
        return self["visibility"]

    @property
    def categories(self):
        """A list of our categories"""
        data = self["categories"].copy().values()
        return [StreamCategory(jsondata_block) for jsondata_block in data]

    @property
    def likes(self):
        """Number of likes on the stream"""
        return self["likes"]

    @property
    def dislikes(self):
        """Number of dislikes on the stream"""
        return self["dislikes"]

    @property
    def like_ratio(self):
        """Ratio of people who liked the stream to people who reacted total"""
        try:
            return self.likes / (self.likes + self.dislikes)

        except ZeroDivisionError:
            return None

    @property
    def watching_now(self):
        """The number of people watching now"""
        return self["watching_now"]

    @property
    def chat(self):
        """The livestream chat"""
        return self.__chat

categories property

A list of our categories

chat property

The livestream chat

created_on property

When the livestream was created, in seconds since the Epock UTC

dislikes property

Number of dislikes on the stream

is_live property

Is the stream live?

like_ratio property

Ratio of people who liked the stream to people who reacted total

likes property

Number of likes on the stream

stream_id property

The livestream ID in base 36

stream_id_b10 property

The livestream chat ID (stream ID in base 10)

stream_id_b36 property

The livestream ID in base 36

title property

The title of the livestream

visibility property

Is the stream public, unlisted, or private?

watching_now property

The number of people watching now

__eq__(other)

Is this stream equal to another?

Parameters:

Name Type Description Default
other (str, int, Livestream)

Object to compare to.

required

Returns:

Name Type Description
Comparison (bool, None)

Did it fit the criteria?

Source code in cocorum/__init__.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
def __eq__(self, other):
    """Is this stream equal to another?

Args:
    other (str, int, Livestream): Object to compare to.

Returns:
    Comparison (bool, None): Did it fit the criteria?
    """

    #Check if the compared string is our stream ID
    if isinstance(other, str):
        return self.stream_id == other #or self.title == other

    #check if the compared number is our chat ID (linked to stream ID)
    if isinstance(other, (int, float)):
        return self.stream_id_b10 == other

    #Check if the compared object has the same stream ID
    if hasattr(other, "stream_id"):
        return self.stream_id == utils.ensure_b36(other.stream_id)

    #Check if the compared object has the same chat ID
    if hasattr(other, "stream_id_b10"):
        return self.stream_id_b10 == other.stream_id_b10

__getitem__(key)

Return a key from the JSON, refreshing if necessary

Parameters:

Name Type Description Default
key str

A valid JSON key.

required
Source code in cocorum/__init__.py
195
196
197
198
199
200
201
202
203
204
205
206
207
208
def __getitem__(self, key):
    """Return a key from the JSON, refreshing if necessary

Args:
    key (str): A valid JSON key.
    """

    #The livestream has not disappeared from the API listing,
    #the key requested is not a value that doesn't change,
    #and it has been api.refresh rate since the last time we refreshed
    if (not self.is_disappeared) and (key not in static.StaticAPIEndpoints.main) and (time.time() - self.api.last_refresh_time > self.api.refresh_rate):
        self.api.refresh()

    return self._jsondata[key]

__init__(jsondata, api)

Rumble livestream

Parameters:

Name Type Description Default
jsondata dict

The JSON block for a single livestream.

required
api RumbleAPI

The Rumble Live Stream API wrapper that spawned us.

required
Source code in cocorum/__init__.py
152
153
154
155
156
157
158
159
160
161
162
163
def __init__(self, jsondata, api):
    """Rumble livestream

Args:
    jsondata (dict): The JSON block for a single livestream.
    api (RumbleAPI): The Rumble Live Stream API wrapper that spawned us.
    """

    self._jsondata = jsondata
    self.api = api
    self.is_disappeared = False #The livestream is in the API listing
    self.__chat = LiveChat(self)

__str__()

The livestream in string form (it's ID in base 36)

Source code in cocorum/__init__.py
191
192
193
def __str__(self):
    """The livestream in string form (it's ID in base 36)"""
    return self.stream_id

Rant

Bases: ChatMessage

A single rant in a Rumble livestream chat

Source code in cocorum/__init__.py
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
class Rant(ChatMessage):
    """A single rant in a Rumble livestream chat"""
    def __eq__(self, other):
        """Is this category equal to another?

    Args:
        other (str, ChatMessage): Object to compare to.

    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """

        #Check if the compared string is our message
        if isinstance(other, str):
            return self.text == other

        # #Check if the compared rant has the same username, amount, and text (unneccesary?)
        # if type(other) == type(self):
        #     return (self.username, self.amount_cents, self.text) == (other.username, other.amount_cents, other.text)

        #Check if the compared object has the same text
        if hasattr(other, "text"):
            #Check if the compared object has the same username, if it has one
            if hasattr(other, "username"):
                #Check if the compared object has the same cost amount, if it has one
                if hasattr(other, "amount_cents"):
                    return (self.username, self.amount_cents, self.text) == (other.username, other.amount_cents, other.text)

                #Other object has no amount_cents attribute
                return (self.username, self.text) == (other.username, other.text)

            #Other object had no username attribute
            return self.text == other.text

    @property
    def expires_on(self):
        """When the rant will expire, in seconds since the Epoch UTC"""
        return self["expires_on"]

    @property
    def amount_cents(self):
        """The total rant amount in cents"""
        return self["amount_cents"]

    @property
    def amount_dollars(self):
        """The rant amount in dollars"""
        return self["amount_dollars"]

amount_cents property

The total rant amount in cents

amount_dollars property

The rant amount in dollars

expires_on property

When the rant will expire, in seconds since the Epoch UTC

__eq__(other)

Is this category equal to another?

Parameters:

Name Type Description Default
other (str, ChatMessage)

Object to compare to.

required

Returns:

Name Type Description
Comparison (bool, None)

Did it fit the criteria?

Source code in cocorum/__init__.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
def __eq__(self, other):
    """Is this category equal to another?

Args:
    other (str, ChatMessage): Object to compare to.

Returns:
    Comparison (bool, None): Did it fit the criteria?
    """

    #Check if the compared string is our message
    if isinstance(other, str):
        return self.text == other

    # #Check if the compared rant has the same username, amount, and text (unneccesary?)
    # if type(other) == type(self):
    #     return (self.username, self.amount_cents, self.text) == (other.username, other.amount_cents, other.text)

    #Check if the compared object has the same text
    if hasattr(other, "text"):
        #Check if the compared object has the same username, if it has one
        if hasattr(other, "username"):
            #Check if the compared object has the same cost amount, if it has one
            if hasattr(other, "amount_cents"):
                return (self.username, self.amount_cents, self.text) == (other.username, other.amount_cents, other.text)

            #Other object has no amount_cents attribute
            return (self.username, self.text) == (other.username, other.text)

        #Other object had no username attribute
        return self.text == other.text

RumbleAPI

Rumble Live Stream API wrapper

Source code in cocorum/__init__.py
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
class RumbleAPI():
    """Rumble Live Stream API wrapper"""
    def __init__(self, api_url, refresh_rate = static.Delays.api_refresh_default):
        """Rumble Live Stream API wrapper
    Args:
        api_url (str): The Rumble API URL, with the key.
        refresh_rate (int, float): How long to reuse queried data before refreshing.
            Defaults to static.Delays.api_refresh_default.
        """

        self.refresh_rate = refresh_rate
        self.last_refresh_time = 0
        self.last_newfollower_time = time.time()
        self.last_newsubscriber_time = time.time()
        self.__livestreams = {}
        self._jsondata = {}
        self.api_url = api_url

        #Warn about refresh rate being below minimum
        if self.refresh_rate < static.Delays.api_refresh_minimum:
            warnings.warn(f"Cocorum set to over-refresh, rate of {self.refresh_rate} seconds (less than {static.Delays.api_refresh_minimum})." + \
                "Superscript must self-limit or Rumble will reject queries!")

    @property
    def api_url(self):
        """Our API URL"""
        return self.__api_url

    @api_url.setter
    def api_url(self, url):
        """Set a new API URL, and refresh

    Args:
        url (str): The new API URL to use.
        """

        self.__api_url = url
        self.refresh()

    def __getitem__(self, key):
        """Return a key from the JSON, refreshing if necessary

    Args:
        key (str): A valid JSON key.
        """

        #This is not a static key, and it's time to refresh our data
        if key not in static.StaticAPIEndpoints.main and time.time() - self.last_refresh_time > self.refresh_rate:
            self.refresh()

        return self._jsondata[key]

    def check_refresh(self):
        """Refresh only if we are past the refresh rate"""
        if time.time() - self.last_refresh_time > self.refresh_rate:
            self.refresh()

    def refresh(self):
        """Reload data from the API"""
        self.last_refresh_time = time.time()
        response = requests.get(self.api_url, headers = static.RequestHeaders.user_agent, timeout = static.Delays.request_timeout)
        assert response.status_code == 200, "Status code " + str(response.status_code)

        self._jsondata = response.json()

        #Remove livestream references that are no longer listed
        listed_ids = [jsondata["id"] for jsondata in self._jsondata["livestreams"]]
        for stream_id in self.__livestreams.copy():
            if stream_id not in listed_ids:
                self.__livestreams[stream_id].is_disappeared = True
                del self.__livestreams[stream_id]

        #Update livestream references' JSONs in-place
        for jsondata in self._jsondata["livestreams"]:
            try:
                #Update the JSON of the stored livestream
                self.__livestreams[jsondata["id"]]._jsondata = jsondata

            except KeyError: #The livestream has not been stored yet
                self.__livestreams[jsondata["id"]] = Livestream(jsondata, self)

    @property
    def data_timestamp(self):
        """The timestamp on the last data refresh"""
        #Definitely don't ever trigger a refresh on this
        return self._jsondata["now"]

    @property
    def api_type(self):
        """Type of API URL in use, user or channel"""
        return self["type"]

    @property
    def user_id(self):
        """The user ID in base 36"""
        return self["user_id"]

    @property
    def user_id_b36(self):
        """The user ID in base 36"""
        return self.user_id

    @property
    def user_id_b10(self):
        """The user ID in base 10"""
        return utils.base_36_to_10(self.user_id)

    @property
    def username(self):
        """The username"""
        return self["username"]

    @property
    def channel_id(self):
        """The channel ID, if we are a channel"""
        return self["channel_id"]

    @property
    def channel_name(self):
        """The channel name, if we are a channel"""
        return self["channel_name"]

    @property
    def num_followers(self):
        """The number of followers of this user or channel"""
        return self["followers"]["num_followers"]

    @property
    def num_followers_total(self):
        """The total number of followers of this account across all channels"""
        return self["followers"]["num_followers_total"]

    @property
    def latest_follower(self):
        """The latest follower of this user or channel"""
        if not self["followers"]["latest_follower"]:
            return None #No-one has followed this user or channel yet
        return Follower(self["followers"]["latest_follower"])

    @property
    def recent_followers(self):
        """A list of recent followers"""
        data = self["followers"]["recent_followers"].copy()
        return [Follower(jsondata_block) for jsondata_block in data]

    @property
    def new_followers(self):
        """Followers that are newer than the last time this was checked (or newer than RumbleAPI object creation)"""
        recent_followers = self.recent_followers

        nf = [follower for follower in recent_followers if follower.followed_on > self.last_newfollower_time]
        nf.sort(key = lambda x: x.followed_on)

        self.last_newfollower_time = time.time()

        return nf

    @property
    def num_subscribers(self):
        """The number of subscribers of this user or channel"""
        return self["subscribers"]["num_subscribers"]

    @property
    def num_subscribers_total(self):
        """The total number of subscribers of this account across all channels"""
        return self["subscribers"]["num_subscribers_total"]

    @property
    def latest_subscriber(self):
        """The latest subscriber of this user or channel"""
        if not self["subscribers"]["latest_subscriber"]:
            return None #No-one has subscribed to this user or channel yet
        return Subscriber(self["subscribers"]["latest_subscriber"])

    @property
    def recent_subscribers(self):
        """A list of recent subscribers (shallow)"""
        data = self["subscribers"]["recent_subscribers"].copy()
        return [Subscriber(jsondata_block) for jsondata_block in data]

    @property
    def new_subscribers(self):
        """Subscribers that are newer than the last time this was checked (or newer than RumbleAPI object creation)"""
        recent_subscribers = self.recent_subscribers

        ns = [subscriber for subscriber in recent_subscribers if subscriber.subscribed_on > self.last_newsubscriber_time]
        ns.sort(key = lambda x: x.subscribed_on)

        self.last_newsubscriber_time = time.time()

        return ns

    @property
    def livestreams(self):
        """A dictionairy of our livestreams"""
        self.check_refresh()
        return self.__livestreams

    @property
    def latest_livestream(self):
        """Return latest livestream to be created. Use this to get a single running livestream"""
        if not self.livestreams:
            return None #No livestreams are running
        return max(self.livestreams.values(), key = lambda x: x.created_on)

api_type property

Type of API URL in use, user or channel

api_url property writable

Our API URL

channel_id property

The channel ID, if we are a channel

channel_name property

The channel name, if we are a channel

data_timestamp property

The timestamp on the last data refresh

latest_follower property

The latest follower of this user or channel

latest_livestream property

Return latest livestream to be created. Use this to get a single running livestream

latest_subscriber property

The latest subscriber of this user or channel

livestreams property

A dictionairy of our livestreams

new_followers property

Followers that are newer than the last time this was checked (or newer than RumbleAPI object creation)

new_subscribers property

Subscribers that are newer than the last time this was checked (or newer than RumbleAPI object creation)

num_followers property

The number of followers of this user or channel

num_followers_total property

The total number of followers of this account across all channels

num_subscribers property

The number of subscribers of this user or channel

num_subscribers_total property

The total number of subscribers of this account across all channels

recent_followers property

A list of recent followers

recent_subscribers property

A list of recent subscribers (shallow)

user_id property

The user ID in base 36

user_id_b10 property

The user ID in base 10

user_id_b36 property

The user ID in base 36

username property

The username

__getitem__(key)

Return a key from the JSON, refreshing if necessary

Parameters:

Name Type Description Default
key str

A valid JSON key.

required
Source code in cocorum/__init__.py
495
496
497
498
499
500
501
502
503
504
505
506
def __getitem__(self, key):
    """Return a key from the JSON, refreshing if necessary

Args:
    key (str): A valid JSON key.
    """

    #This is not a static key, and it's time to refresh our data
    if key not in static.StaticAPIEndpoints.main and time.time() - self.last_refresh_time > self.refresh_rate:
        self.refresh()

    return self._jsondata[key]

__init__(api_url, refresh_rate=static.Delays.api_refresh_default)

Rumble Live Stream API wrapper Args: api_url (str): The Rumble API URL, with the key. refresh_rate (int, float): How long to reuse queried data before refreshing. Defaults to static.Delays.api_refresh_default.

Source code in cocorum/__init__.py
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
def __init__(self, api_url, refresh_rate = static.Delays.api_refresh_default):
    """Rumble Live Stream API wrapper
Args:
    api_url (str): The Rumble API URL, with the key.
    refresh_rate (int, float): How long to reuse queried data before refreshing.
        Defaults to static.Delays.api_refresh_default.
    """

    self.refresh_rate = refresh_rate
    self.last_refresh_time = 0
    self.last_newfollower_time = time.time()
    self.last_newsubscriber_time = time.time()
    self.__livestreams = {}
    self._jsondata = {}
    self.api_url = api_url

    #Warn about refresh rate being below minimum
    if self.refresh_rate < static.Delays.api_refresh_minimum:
        warnings.warn(f"Cocorum set to over-refresh, rate of {self.refresh_rate} seconds (less than {static.Delays.api_refresh_minimum})." + \
            "Superscript must self-limit or Rumble will reject queries!")

check_refresh()

Refresh only if we are past the refresh rate

Source code in cocorum/__init__.py
508
509
510
511
def check_refresh(self):
    """Refresh only if we are past the refresh rate"""
    if time.time() - self.last_refresh_time > self.refresh_rate:
        self.refresh()

refresh()

Reload data from the API

Source code in cocorum/__init__.py
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
def refresh(self):
    """Reload data from the API"""
    self.last_refresh_time = time.time()
    response = requests.get(self.api_url, headers = static.RequestHeaders.user_agent, timeout = static.Delays.request_timeout)
    assert response.status_code == 200, "Status code " + str(response.status_code)

    self._jsondata = response.json()

    #Remove livestream references that are no longer listed
    listed_ids = [jsondata["id"] for jsondata in self._jsondata["livestreams"]]
    for stream_id in self.__livestreams.copy():
        if stream_id not in listed_ids:
            self.__livestreams[stream_id].is_disappeared = True
            del self.__livestreams[stream_id]

    #Update livestream references' JSONs in-place
    for jsondata in self._jsondata["livestreams"]:
        try:
            #Update the JSON of the stored livestream
            self.__livestreams[jsondata["id"]]._jsondata = jsondata

        except KeyError: #The livestream has not been stored yet
            self.__livestreams[jsondata["id"]] = Livestream(jsondata, self)

StreamCategory

Bases: JSONObj

Category of a Rumble stream

Source code in cocorum/__init__.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
class StreamCategory(JSONObj):
    """Category of a Rumble stream"""

    @property
    def slug(self):
        """Return the category's slug, AKA it's ID"""
        return self["slug"]

    @property
    def title(self):
        """Return the category's title"""
        return self["title"]

    def __eq__(self, other):
        """Is this category equal to another?

    Args:
        other (str, StreamCategory): Other object to compare to.

    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """

        #Check if the compared string is our slug or title
        if isinstance(other, str):
            return other in (self.slug, self.title)

        #Check if the compared object has the same slug, if it has one
        if hasattr(other, "slug"):
            return self.slug == other.slug

    def __str__(self):
        """The category in string form"""
        return self.title

slug property

Return the category's slug, AKA it's ID

title property

Return the category's title

__eq__(other)

Is this category equal to another?

Parameters:

Name Type Description Default
other (str, StreamCategory)

Other object to compare to.

required

Returns:

Name Type Description
Comparison (bool, None)

Did it fit the criteria?

Source code in cocorum/__init__.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def __eq__(self, other):
    """Is this category equal to another?

Args:
    other (str, StreamCategory): Other object to compare to.

Returns:
    Comparison (bool, None): Did it fit the criteria?
    """

    #Check if the compared string is our slug or title
    if isinstance(other, str):
        return other in (self.slug, self.title)

    #Check if the compared object has the same slug, if it has one
    if hasattr(other, "slug"):
        return self.slug == other.slug

__str__()

The category in string form

Source code in cocorum/__init__.py
146
147
148
def __str__(self):
    """The category in string form"""
    return self.title

Subscriber

Bases: JSONUserAction

Rumble subscriber

Source code in cocorum/__init__.py
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
class Subscriber(JSONUserAction):
    """Rumble subscriber"""
    def __eq__(self, other):
        """Is this subscriber equal to another?

    Args:
        other (str, JSONUserAction, Subscriber): The other object to compare to.

    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """

        #Check if the compared string is our username
        if isinstance(other, str):
            return self.username == other

        #check if the compared number is our amount in cents
        # if isinstance(other, (int, float)):
            # return self.amount_cents == other

        #Check if the compared object's username matches our own, if it has one
        if hasattr(other, "username"):
            #Check if the compared object's cost amout matches our own, if it has one
            if hasattr(other, "amount_cents"):
                return self.amount_cents == other.amount_cents

            #Other object has no amount_cents attribute
            return self.username == other.username

    @property
    def user(self):
        """AFAIK this is being deprecated, use username instead"""
        return self["user"]

    @property
    def amount_cents(self):
        """The total subscription amount in cents"""
        return self["amount_cents"]

    @property
    def amount_dollars(self):
        """The subscription amount in dollars"""
        return self["amount_dollars"]

    @property
    def subscribed_on(self):
        """When the subscriber subscribed, in seconds since Epoch UTC"""
        return utils.parse_timestamp(self["subscribed_on"])

amount_cents property

The total subscription amount in cents

amount_dollars property

The subscription amount in dollars

subscribed_on property

When the subscriber subscribed, in seconds since Epoch UTC

user property

AFAIK this is being deprecated, use username instead

__eq__(other)

Is this subscriber equal to another?

Parameters:

Name Type Description Default
other (str, JSONUserAction, Subscriber)

The other object to compare to.

required

Returns:

Name Type Description
Comparison (bool, None)

Did it fit the criteria?

Source code in cocorum/__init__.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def __eq__(self, other):
    """Is this subscriber equal to another?

Args:
    other (str, JSONUserAction, Subscriber): The other object to compare to.

Returns:
    Comparison (bool, None): Did it fit the criteria?
    """

    #Check if the compared string is our username
    if isinstance(other, str):
        return self.username == other

    #check if the compared number is our amount in cents
    # if isinstance(other, (int, float)):
        # return self.amount_cents == other

    #Check if the compared object's username matches our own, if it has one
    if hasattr(other, "username"):
        #Check if the compared object's cost amout matches our own, if it has one
        if hasattr(other, "amount_cents"):
            return self.amount_cents == other.amount_cents

        #Other object has no amount_cents attribute
        return self.username == other.username

cocorum.chatapi

Internal chat API client

This part of cocorum is not part of the official Rumble Live Stream API, but may provide a more reliable method of ensuring all chat messages are received. It also can do to-chat interactions, sometimes via Service.PHP.

Example usage:

from cocorum import chatapi

#Additionally pass username and password for to-chat interactions
chat = chatapi.ChatAPI(stream_id = STREAM_ID) #Stream ID can be base 10 or 36
chat.clear_mailbox() #Erase messages that were still visible before we connected

#Get messages for one minute
start_time = time.time()
while time.time() - start_time < 60 and (msg := chat.get_message()):
    print(msg.user.username, "said", msg)

S.D.G.

ChatAPI

The Rumble internal chat API

Source code in cocorum/chatapi.py
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
class ChatAPI():
    """The Rumble internal chat API"""
    def __init__(self, stream_id, username: str = None, password: str = None, session = None):
        """The Rumble internal chat API

    Args:
        stream_id (int, str): Stream ID in base 10 or 36.
        username (str): Username to login with.
            Defaults to no login.
        password (str): Password to log in with.
            Defaults to no login.
        session (str, dict): Session token or cookie dict to authenticate with.
            Defaults to getting new session with username and password.
            """

        self.stream_id = utils.ensure_b36(stream_id)

        self.__mailbox = [] #A mailbox if you will
        self.deleted_message_ids = [] #IDs of messages that were deleted, as reported by the client
        self.pinned_message = None #If a message is pinned, it is assigned to this
        self.users = {} #Dictionary of users by user ID
        self.channels = {} #Dictionary of channels by channel ID
        self.badges = {}

        #Generate our URLs
        self.sse_url = static.URI.ChatAPI.sse_stream.format(stream_id_b10 = self.stream_id_b10)
        self.message_api_url = static.URI.ChatAPI.message.format(stream_id_b10 = self.stream_id_b10)

        #Connect to SSE stream
        #Note: We do NOT want this request to have a timeout
        response = requests.get(self.sse_url, stream = True, headers = static.RequestHeaders.sse_api)
        self.client = sseclient.SSEClient(response)
        self.event_generator = self.client.events()
        self.chat_running = True
        self.parse_init_data(self.next_jsondata())

        #If we have session login, use them
        if (username and password) or session:
            self.servicephp = ServicePHP(username, password, session)
            self.scraper = scraping.Scraper(self.servicephp)
        else:
            self.servicephp = None

        #The last time we sent a message
        self.last_send_time = 0

    @property
    def session_cookie(self):
        """The session cookie we are logged in with"""
        if self.servicephp:
            return self.servicephp.session_cookie
        return None

    def send_message(self, text: str, channel_id: int = None):
        """Send a message in chat.

    Args:
        text (str): The message text.
        channel_id (int): Numeric ID of the channel to use.
            Defaults to None.

    Returns:
        ID (int): The ID of the sent message.
        User (ChatAPIUser): Your current chat user information.
        """

        assert self.session_cookie, "Not logged in, cannot send message"
        assert len(text) <= static.Message.max_len, "Mesage is too long"
        curtime = time.time()
        assert self.last_send_time + static.Message.send_cooldown <= curtime, "Sending messages too fast"
        assert utils.options_check(self.message_api_url, "POST"), "Rumble denied options request to post message"
        r = requests.post(
            self.message_api_url,
            cookies = self.session_cookie,
            json = {
                "data": {
                    "request_id": utils.generate_request_id(),
                    "message": {
                        "text": text
                    },
                    "rant": None,
                    "channel_id": channel_id
                    }
                },
            # headers = static.RequestHeaders.user_agent,
            timeout = static.Delays.request_timeout,
            )

        if r.status_code != 200:
            print("Error: Sending message failed,", r, r.text)
            return

        return int(r.json()["data"]["id"]), ChatAPIUser(r.json()["data"]["user"], self)

    def command(self, command_message: str):
        """Send a native chat command

    Args:
        command_message (str): The message you would send to launch this command in chat.

    Returns:
        JSON (dict): The JSON returned by the command.
        """

        assert command_message.startswith(static.Message.command_prefix), "Not a command message"
        r = requests.post(
            static.URI.ChatAPI.command,
            data = {
                "video_id" : self.stream_id_b10,
                "message" : command_message,
                },
            cookies = self.session_cookie,
            headers = static.RequestHeaders.user_agent,
            timeout = static.Delays.request_timeout,
            )
        assert r.status_code == 200, f"Command failed: {r}\n{r.text}"
        return r.json()

    def delete_message(self, message):
        """Delete a message in chat.

    Args:
        message (int): Object which when converted to integer is the target message ID.
        """

        assert self.session_cookie, "Not logged in, cannot delete message"
        assert utils.options_check(self.message_api_url + f"/{int(message)}", "DELETE"), "Rumble denied options request to delete message"

        r = requests.delete(
            self.message_api_url + f"/{int(message)}",
            cookies = self.session_cookie,
            # headers = static.RequestHeaders.user_agent,
            timeout = static.Delays.request_timeout,
            )

        if r.status_code != 200:
            print("Error: Deleting message failed,", r, r.content.decode(static.Misc.text_encoding))
            return False

        return True

    def pin_message(self, message):
        """Pin a message"""
        assert self.session_cookie, "Not logged in, cannot pin message"
        return self.servicephp.chat_pin(self.stream_id_b10, message)

    def unpin_message(self, message = None):
        """Unpin the pinned message"""
        assert self.session_cookie, "Not logged in, cannot unpin message"
        if not message:
            message = self.pinned_message
        assert message, "No known pinned message and ID not provided"
        return self.servicephp.chat_pin(self.stream_id_b10, message, unpin = True)

    def mute_user(self, user, duration: int = None, total: bool = False):
        """Mute a user.

    Args:
        user (str): Username to mute.
        duration (int): How long to mute the user in seconds.
            Defaults to infinite.
        total (bool): Wether or not they are muted across all videos.
            Defaults to False, just this video.
            """

        assert self.session_cookie, "Not logged in, cannot mute user"
        return self.servicephp.mute_user(
            username = str(user),
            is_channel = False,
            video = self.stream_id_b10,
            duration = duration,
            total = total
            )

    def unmute_user(self, user):
        """Unmute a user.

    Args:
        user (str): Username to unmute
        """

        assert self.session_cookie, "Not logged in, cannot unmute user"

        #If the user object has a username attribute, use that
        # because most user objects will __str__ into their base 36 ID
        if hasattr(user, "username"):
            user = user.username

        record_id = self.scraper.get_muted_user_record(str(user))
        assert record_id, "User was not in muted records"
        return self.servicephp.unmute_user(record_id)

    def next_jsondata(self):
        """Wait for the next event from the SSE and parse the JSON"""
        if not self.chat_running: #Do not try to query a new event if chat is closed
            print("Chat closed, cannot retrieve new JSON data.")
            return

        try:
            event = next(self.event_generator, None)
        except requests.exceptions.ReadTimeout:
            event = None

        if not event:
            self.chat_running = False #Chat has been closed
            print("Chat has closed.")
            return
        if not event.data: #Blank SSE event
            print("Blank SSE event:>", event, "<:")
            #Self recursion should work so long as we don't get dozens of blank events in a row
            return self.next_jsondata()

        return json.loads(event.data)

    def parse_init_data(self, jsondata):
        """Extract initial chat data from the SSE init event JSON

    Args:
        jsondata (dict): The JSON data returned by the initial SSE connection.
        """

        if jsondata["type"] != "init":
            print(jsondata)
            raise ValueError("That is not init json")

        #Parse pre-connection users, channels, then messages
        self.update_users(jsondata)
        self.update_channels(jsondata)
        self.update_mailbox(jsondata)

        #Load the chat badges
        self.load_badges(jsondata)

        self.rants_enabled = jsondata["data"]["config"]["rants"]["enable"]
        #subscription TODO
        #rant levels TODO
        self.message_length_max = jsondata["data"]["config"]["message_length_max"]

    def update_mailbox(self, jsondata):
        """Parse chat messages from an SSE data JSON

    Args:
        jsondata (dict): A JSON data block from an SSE event.
        """

        #Add new messages
        self.__mailbox += [ChatAPIMessage(message_json, self) for message_json in jsondata["data"]["messages"] if int(message_json["id"]) not in self.__mailbox]

    def clear_mailbox(self):
        """Delete anything in the mailbox"""
        self.__mailbox = []

    def clear_deleted_message_ids(self):
        """Clear and return the list of deleted message IDs"""
        del_m = self.deleted_message_ids.copy()
        self.deleted_message_ids = []
        return del_m

    def update_users(self, jsondata):
        """Update our dictionary of users from an SSE data JSON

    Args:
        jsondata (dict): A JSON data block from an SSE event.
        """

        for user_json in jsondata["data"]["users"]:
            try:
                self.users[int(user_json["id"])]._jsondata = user_json #Update an existing user's JSON
            except KeyError: #User is new
                self.users[int(user_json["id"])] = ChatAPIUser(user_json, self)

    def update_channels(self, jsondata):
        """Update our dictionary of channels from an SSE data JSON

    Args:
        jsondata (dict): A JSON data block from an SSE event.
        """

        for channel_json in jsondata["data"]["channels"]:
            try:
                self.channels[int(channel_json["id"])]._jsondata = channel_json #Update an existing channel's JSON
            except KeyError: #Channel is new
                self.channels.update({int(channel_json["id"]) : ChatAPIChannel(channel_json, self)})

    def load_badges(self, jsondata):
        """Create our dictionary of badges from an SSE data JSON

    Args:
        jsondata (dict): A JSON data block from an SSE event.
        """

        self.badges = {badge_slug : ChatAPIUserBadge(badge_slug, jsondata["data"]["config"]["badges"][badge_slug], self) for badge_slug in jsondata["data"]["config"]["badges"].keys()}

    @property
    def stream_id_b10(self):
        """The chat ID in use"""
        return utils.base_36_to_10(self.stream_id)

    def get_message(self):
        """Return the next chat message (parsing any additional data), waits for it to come in, returns None if chat closed"""
        #We don't already have messages
        while not self.__mailbox:
            jsondata = self.next_jsondata()

            #The chat has closed
            if not jsondata:
                return

            #Messages were deleted
            if jsondata["type"] in ("delete_messages", "delete_non_rant_messages"):
                self.deleted_message_ids += jsondata["data"]["message_ids"]

            #Re-initialize (could contain new messages)
            elif jsondata["type"] == "init":
                self.parse_init_data(jsondata)

            #Pinned message
            elif jsondata["type"] == "pin_message":
                self.pinned_message = ChatAPIMessage(jsondata["data"]["message"], self)

            #New messages
            elif jsondata["type"] == "messages":
                #Parse users, channels, then messages
                self.update_users(jsondata)
                self.update_channels(jsondata)
                self.update_mailbox(jsondata)

            #Unimplemented event type
            else:
                print("API sent an unimplemented SSE event type")
                print(jsondata)

        return self.__mailbox.pop(0) #Return the first message in the mailbox, and then remove it from there

The session cookie we are logged in with

stream_id_b10 property

The chat ID in use

__init__(stream_id, username=None, password=None, session=None)

The Rumble internal chat API

Parameters:

Name Type Description Default
stream_id (int, str)

Stream ID in base 10 or 36.

required
username str

Username to login with. Defaults to no login.

None
password str

Password to log in with. Defaults to no login.

None
session (str, dict)

Session token or cookie dict to authenticate with. Defaults to getting new session with username and password.

None
Source code in cocorum/chatapi.py
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
def __init__(self, stream_id, username: str = None, password: str = None, session = None):
    """The Rumble internal chat API

Args:
    stream_id (int, str): Stream ID in base 10 or 36.
    username (str): Username to login with.
        Defaults to no login.
    password (str): Password to log in with.
        Defaults to no login.
    session (str, dict): Session token or cookie dict to authenticate with.
        Defaults to getting new session with username and password.
        """

    self.stream_id = utils.ensure_b36(stream_id)

    self.__mailbox = [] #A mailbox if you will
    self.deleted_message_ids = [] #IDs of messages that were deleted, as reported by the client
    self.pinned_message = None #If a message is pinned, it is assigned to this
    self.users = {} #Dictionary of users by user ID
    self.channels = {} #Dictionary of channels by channel ID
    self.badges = {}

    #Generate our URLs
    self.sse_url = static.URI.ChatAPI.sse_stream.format(stream_id_b10 = self.stream_id_b10)
    self.message_api_url = static.URI.ChatAPI.message.format(stream_id_b10 = self.stream_id_b10)

    #Connect to SSE stream
    #Note: We do NOT want this request to have a timeout
    response = requests.get(self.sse_url, stream = True, headers = static.RequestHeaders.sse_api)
    self.client = sseclient.SSEClient(response)
    self.event_generator = self.client.events()
    self.chat_running = True
    self.parse_init_data(self.next_jsondata())

    #If we have session login, use them
    if (username and password) or session:
        self.servicephp = ServicePHP(username, password, session)
        self.scraper = scraping.Scraper(self.servicephp)
    else:
        self.servicephp = None

    #The last time we sent a message
    self.last_send_time = 0

clear_deleted_message_ids()

Clear and return the list of deleted message IDs

Source code in cocorum/chatapi.py
674
675
676
677
678
def clear_deleted_message_ids(self):
    """Clear and return the list of deleted message IDs"""
    del_m = self.deleted_message_ids.copy()
    self.deleted_message_ids = []
    return del_m

clear_mailbox()

Delete anything in the mailbox

Source code in cocorum/chatapi.py
670
671
672
def clear_mailbox(self):
    """Delete anything in the mailbox"""
    self.__mailbox = []

command(command_message)

Send a native chat command

Parameters:

Name Type Description Default
command_message str

The message you would send to launch this command in chat.

required

Returns:

Name Type Description
JSON dict

The JSON returned by the command.

Source code in cocorum/chatapi.py
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
def command(self, command_message: str):
    """Send a native chat command

Args:
    command_message (str): The message you would send to launch this command in chat.

Returns:
    JSON (dict): The JSON returned by the command.
    """

    assert command_message.startswith(static.Message.command_prefix), "Not a command message"
    r = requests.post(
        static.URI.ChatAPI.command,
        data = {
            "video_id" : self.stream_id_b10,
            "message" : command_message,
            },
        cookies = self.session_cookie,
        headers = static.RequestHeaders.user_agent,
        timeout = static.Delays.request_timeout,
        )
    assert r.status_code == 200, f"Command failed: {r}\n{r.text}"
    return r.json()

delete_message(message)

Delete a message in chat.

Parameters:

Name Type Description Default
message int

Object which when converted to integer is the target message ID.

required
Source code in cocorum/chatapi.py
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
def delete_message(self, message):
    """Delete a message in chat.

Args:
    message (int): Object which when converted to integer is the target message ID.
    """

    assert self.session_cookie, "Not logged in, cannot delete message"
    assert utils.options_check(self.message_api_url + f"/{int(message)}", "DELETE"), "Rumble denied options request to delete message"

    r = requests.delete(
        self.message_api_url + f"/{int(message)}",
        cookies = self.session_cookie,
        # headers = static.RequestHeaders.user_agent,
        timeout = static.Delays.request_timeout,
        )

    if r.status_code != 200:
        print("Error: Deleting message failed,", r, r.content.decode(static.Misc.text_encoding))
        return False

    return True

get_message()

Return the next chat message (parsing any additional data), waits for it to come in, returns None if chat closed

Source code in cocorum/chatapi.py
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
def get_message(self):
    """Return the next chat message (parsing any additional data), waits for it to come in, returns None if chat closed"""
    #We don't already have messages
    while not self.__mailbox:
        jsondata = self.next_jsondata()

        #The chat has closed
        if not jsondata:
            return

        #Messages were deleted
        if jsondata["type"] in ("delete_messages", "delete_non_rant_messages"):
            self.deleted_message_ids += jsondata["data"]["message_ids"]

        #Re-initialize (could contain new messages)
        elif jsondata["type"] == "init":
            self.parse_init_data(jsondata)

        #Pinned message
        elif jsondata["type"] == "pin_message":
            self.pinned_message = ChatAPIMessage(jsondata["data"]["message"], self)

        #New messages
        elif jsondata["type"] == "messages":
            #Parse users, channels, then messages
            self.update_users(jsondata)
            self.update_channels(jsondata)
            self.update_mailbox(jsondata)

        #Unimplemented event type
        else:
            print("API sent an unimplemented SSE event type")
            print(jsondata)

    return self.__mailbox.pop(0) #Return the first message in the mailbox, and then remove it from there

load_badges(jsondata)

Create our dictionary of badges from an SSE data JSON

Parameters:

Name Type Description Default
jsondata dict

A JSON data block from an SSE event.

required
Source code in cocorum/chatapi.py
706
707
708
709
710
711
712
713
def load_badges(self, jsondata):
    """Create our dictionary of badges from an SSE data JSON

Args:
    jsondata (dict): A JSON data block from an SSE event.
    """

    self.badges = {badge_slug : ChatAPIUserBadge(badge_slug, jsondata["data"]["config"]["badges"][badge_slug], self) for badge_slug in jsondata["data"]["config"]["badges"].keys()}

mute_user(user, duration=None, total=False)

Mute a user.

Parameters:

Name Type Description Default
user str

Username to mute.

required
duration int

How long to mute the user in seconds. Defaults to infinite.

None
total bool

Wether or not they are muted across all videos. Defaults to False, just this video.

False
Source code in cocorum/chatapi.py
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
def mute_user(self, user, duration: int = None, total: bool = False):
    """Mute a user.

Args:
    user (str): Username to mute.
    duration (int): How long to mute the user in seconds.
        Defaults to infinite.
    total (bool): Wether or not they are muted across all videos.
        Defaults to False, just this video.
        """

    assert self.session_cookie, "Not logged in, cannot mute user"
    return self.servicephp.mute_user(
        username = str(user),
        is_channel = False,
        video = self.stream_id_b10,
        duration = duration,
        total = total
        )

next_jsondata()

Wait for the next event from the SSE and parse the JSON

Source code in cocorum/chatapi.py
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
def next_jsondata(self):
    """Wait for the next event from the SSE and parse the JSON"""
    if not self.chat_running: #Do not try to query a new event if chat is closed
        print("Chat closed, cannot retrieve new JSON data.")
        return

    try:
        event = next(self.event_generator, None)
    except requests.exceptions.ReadTimeout:
        event = None

    if not event:
        self.chat_running = False #Chat has been closed
        print("Chat has closed.")
        return
    if not event.data: #Blank SSE event
        print("Blank SSE event:>", event, "<:")
        #Self recursion should work so long as we don't get dozens of blank events in a row
        return self.next_jsondata()

    return json.loads(event.data)

parse_init_data(jsondata)

Extract initial chat data from the SSE init event JSON

Parameters:

Name Type Description Default
jsondata dict

The JSON data returned by the initial SSE connection.

required
Source code in cocorum/chatapi.py
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
def parse_init_data(self, jsondata):
    """Extract initial chat data from the SSE init event JSON

Args:
    jsondata (dict): The JSON data returned by the initial SSE connection.
    """

    if jsondata["type"] != "init":
        print(jsondata)
        raise ValueError("That is not init json")

    #Parse pre-connection users, channels, then messages
    self.update_users(jsondata)
    self.update_channels(jsondata)
    self.update_mailbox(jsondata)

    #Load the chat badges
    self.load_badges(jsondata)

    self.rants_enabled = jsondata["data"]["config"]["rants"]["enable"]
    #subscription TODO
    #rant levels TODO
    self.message_length_max = jsondata["data"]["config"]["message_length_max"]

pin_message(message)

Pin a message

Source code in cocorum/chatapi.py
563
564
565
566
def pin_message(self, message):
    """Pin a message"""
    assert self.session_cookie, "Not logged in, cannot pin message"
    return self.servicephp.chat_pin(self.stream_id_b10, message)

send_message(text, channel_id=None)

Send a message in chat.

Parameters:

Name Type Description Default
text str

The message text.

required
channel_id int

Numeric ID of the channel to use. Defaults to None.

None

Returns:

Name Type Description
ID int

The ID of the sent message.

User ChatAPIUser

Your current chat user information.

Source code in cocorum/chatapi.py
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
def send_message(self, text: str, channel_id: int = None):
    """Send a message in chat.

Args:
    text (str): The message text.
    channel_id (int): Numeric ID of the channel to use.
        Defaults to None.

Returns:
    ID (int): The ID of the sent message.
    User (ChatAPIUser): Your current chat user information.
    """

    assert self.session_cookie, "Not logged in, cannot send message"
    assert len(text) <= static.Message.max_len, "Mesage is too long"
    curtime = time.time()
    assert self.last_send_time + static.Message.send_cooldown <= curtime, "Sending messages too fast"
    assert utils.options_check(self.message_api_url, "POST"), "Rumble denied options request to post message"
    r = requests.post(
        self.message_api_url,
        cookies = self.session_cookie,
        json = {
            "data": {
                "request_id": utils.generate_request_id(),
                "message": {
                    "text": text
                },
                "rant": None,
                "channel_id": channel_id
                }
            },
        # headers = static.RequestHeaders.user_agent,
        timeout = static.Delays.request_timeout,
        )

    if r.status_code != 200:
        print("Error: Sending message failed,", r, r.text)
        return

    return int(r.json()["data"]["id"]), ChatAPIUser(r.json()["data"]["user"], self)

unmute_user(user)

Unmute a user.

Parameters:

Name Type Description Default
user str

Username to unmute

required
Source code in cocorum/chatapi.py
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
def unmute_user(self, user):
    """Unmute a user.

Args:
    user (str): Username to unmute
    """

    assert self.session_cookie, "Not logged in, cannot unmute user"

    #If the user object has a username attribute, use that
    # because most user objects will __str__ into their base 36 ID
    if hasattr(user, "username"):
        user = user.username

    record_id = self.scraper.get_muted_user_record(str(user))
    assert record_id, "User was not in muted records"
    return self.servicephp.unmute_user(record_id)

unpin_message(message=None)

Unpin the pinned message

Source code in cocorum/chatapi.py
568
569
570
571
572
573
574
def unpin_message(self, message = None):
    """Unpin the pinned message"""
    assert self.session_cookie, "Not logged in, cannot unpin message"
    if not message:
        message = self.pinned_message
    assert message, "No known pinned message and ID not provided"
    return self.servicephp.chat_pin(self.stream_id_b10, message, unpin = True)

update_channels(jsondata)

Update our dictionary of channels from an SSE data JSON

Parameters:

Name Type Description Default
jsondata dict

A JSON data block from an SSE event.

required
Source code in cocorum/chatapi.py
693
694
695
696
697
698
699
700
701
702
703
704
def update_channels(self, jsondata):
    """Update our dictionary of channels from an SSE data JSON

Args:
    jsondata (dict): A JSON data block from an SSE event.
    """

    for channel_json in jsondata["data"]["channels"]:
        try:
            self.channels[int(channel_json["id"])]._jsondata = channel_json #Update an existing channel's JSON
        except KeyError: #Channel is new
            self.channels.update({int(channel_json["id"]) : ChatAPIChannel(channel_json, self)})

update_mailbox(jsondata)

Parse chat messages from an SSE data JSON

Parameters:

Name Type Description Default
jsondata dict

A JSON data block from an SSE event.

required
Source code in cocorum/chatapi.py
660
661
662
663
664
665
666
667
668
def update_mailbox(self, jsondata):
    """Parse chat messages from an SSE data JSON

Args:
    jsondata (dict): A JSON data block from an SSE event.
    """

    #Add new messages
    self.__mailbox += [ChatAPIMessage(message_json, self) for message_json in jsondata["data"]["messages"] if int(message_json["id"]) not in self.__mailbox]

update_users(jsondata)

Update our dictionary of users from an SSE data JSON

Parameters:

Name Type Description Default
jsondata dict

A JSON data block from an SSE event.

required
Source code in cocorum/chatapi.py
680
681
682
683
684
685
686
687
688
689
690
691
def update_users(self, jsondata):
    """Update our dictionary of users from an SSE data JSON

Args:
    jsondata (dict): A JSON data block from an SSE event.
    """

    for user_json in jsondata["data"]["users"]:
        try:
            self.users[int(user_json["id"])]._jsondata = user_json #Update an existing user's JSON
        except KeyError: #User is new
            self.users[int(user_json["id"])] = ChatAPIUser(user_json, self)

ChatAPIChannel

Bases: ChatAPIChatter

A channel in the SSE chat

Source code in cocorum/chatapi.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
class ChatAPIChannel(ChatAPIChatter):
    """A channel in the SSE chat"""
    def __init__(self, jsondata, chat):
        """A channel in the internal chat API

    Args:
        jsondata (dict): The JSON data block for the channel.
        chat (ChatAPI): The ChatAPI object that spawned us.
        """

        super().__init__(jsondata, chat)

        #Find the user who has this channel
        for user in self.chat.users.values():
            if user.channel_id == self.channel_id or self.channel_id in user.previous_channel_ids:
                self.user = user
                break

    @property
    def is_appearing(self):
        """Is the user of this channel still appearing as it?"""
        return self.user.channel_id == self.channel_id #The user channel_id still matches our own

    @property
    def channel_id(self):
        """The ID of this channel in base 10"""
        return int(self["id"])

    @property
    def channel_id_b10(self):
        """The ID of this channel in base 10"""
        return self.channel_id

    @property
    def channel_id_b36(self):
        """The ID of this channel in base 36"""
        return utils.base_10_to_36(self.channel_id)

    @property
    def user_id(self):
        """The numeric ID of the user of this channel"""
        return self.user.user_id

    @property
    def user_id_b36(self):
        """The numeric ID of the user of this channel in base 36"""
        return self.user.user_id_b36

    @property
    def user_id_b10(self):
        """The numeric ID of the user of this channel in base 10"""
        return self.user.user_id_b10

channel_id property

The ID of this channel in base 10

channel_id_b10 property

The ID of this channel in base 10

channel_id_b36 property

The ID of this channel in base 36

is_appearing property

Is the user of this channel still appearing as it?

user_id property

The numeric ID of the user of this channel

user_id_b10 property

The numeric ID of the user of this channel in base 10

user_id_b36 property

The numeric ID of the user of this channel in base 36

__init__(jsondata, chat)

A channel in the internal chat API

Parameters:

Name Type Description Default
jsondata dict

The JSON data block for the channel.

required
chat ChatAPI

The ChatAPI object that spawned us.

required
Source code in cocorum/chatapi.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def __init__(self, jsondata, chat):
    """A channel in the internal chat API

Args:
    jsondata (dict): The JSON data block for the channel.
    chat (ChatAPI): The ChatAPI object that spawned us.
    """

    super().__init__(jsondata, chat)

    #Find the user who has this channel
    for user in self.chat.users.values():
        if user.channel_id == self.channel_id or self.channel_id in user.previous_channel_ids:
            self.user = user
            break

ChatAPIChatter

Bases: JSONUserAction, ChatAPIObj

A user or channel in the internal chat API (abstract)

Source code in cocorum/chatapi.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class ChatAPIChatter(JSONUserAction, ChatAPIObj):
    """A user or channel in the internal chat API (abstract)"""
    def __init__(self, jsondata, chat):
        """A user or channel in the internal chat API (abstract)

    Args:
        jsondata (dict): The JSON data block for the user/channel.
        chat (ChatAPI): The ChatAPI object that spawned us.
        """
        ChatAPIObj.__init__(self, jsondata, chat)
        JSONUserAction.__init__(self, jsondata)

    @property
    def link(self):
        """The user's subpage of Rumble.com"""
        return self["link"]

The user's subpage of Rumble.com

__init__(jsondata, chat)

A user or channel in the internal chat API (abstract)

Parameters:

Name Type Description Default
jsondata dict

The JSON data block for the user/channel.

required
chat ChatAPI

The ChatAPI object that spawned us.

required
Source code in cocorum/chatapi.py
47
48
49
50
51
52
53
54
55
def __init__(self, jsondata, chat):
    """A user or channel in the internal chat API (abstract)

Args:
    jsondata (dict): The JSON data block for the user/channel.
    chat (ChatAPI): The ChatAPI object that spawned us.
    """
    ChatAPIObj.__init__(self, jsondata, chat)
    JSONUserAction.__init__(self, jsondata)

ChatAPIMessage

Bases: ChatAPIObj

A single chat message in the internal chat API

Source code in cocorum/chatapi.py
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
class ChatAPIMessage(ChatAPIObj):
    """A single chat message in the internal chat API"""
    def __init__(self, jsondata, chat):
        """A single chat message in the internal chat API

    Args:
        jsondata (dict): The JSON data block for the message.
        chat (ChatAPI): The ChatAPI object that spawned us.
        """

        super().__init__(jsondata, chat)

        #Set the channel ID of our user if we can
        if self.user:
            self.user._set_channel_id = self.channel_id

    def __eq__(self, other):
        """Compare this chat message with another

    Args:
        other (str, ChatAPIMessage): Object to compare to.

    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """

        if isinstance(other, str):
            return self.text == other

        #Check if the other object's text matches our own, if it has such
        if hasattr(other, "text"):
            #Check if the other object's user ID matches our own, if it has one
            if hasattr(other, "user_id"):
                #Check if the other object is a raid notification, if it says
                if hasattr(other, "raid_notification"):
                    return (self.user_id, self.text, self.raid_notification) == (other.user_id, other.text, other.raid_notification)

                return (self.user_id, self.text) == (other.user_id, other.text)

            #Check if the other object's username matches our own, if it has one
            if hasattr(other, "username"):
                #Check if the other object is a raid notification, if it says
                if hasattr(other, "raid_notification"):
                    return (self.user_id, self.text, self.raid_notification) == (other.user_id, other.text, other.raid_notification)

                return (self.user.username, self.text) == (other.username, other.text)

            #No user identifying attributes, but the text does match
            return self.text == other.text

    def __str__(self):
        """The chat message in string form"""
        return self.text

    def __int__(self):
        """The chat message in integer (ID) form"""
        return self.message_id

    @property
    def message_id(self):
        """The unique numerical ID of the chat message in base 10"""
        return int(self["id"])

    @property
    def message_id_b10(self):
        """The unique numerical ID of the chat message in base 10"""
        return self.message_id

    @property
    def message_id_b36(self):
        """The unique numerical ID of the chat message in base 36"""
        return utils.base_10_to_36(self.message_id)

    @property
    def time(self):
        """The time the message was sent on, in seconds since the Epoch UTC"""
        return utils.parse_timestamp(self["time"])

    @property
    def user_id(self):
        """The numerical ID of the user who posted the message in base 10"""
        return int(self["user_id"])

    @property
    def user_id_b10(self):
        """The numeric ID of the user in base 10"""
        return self.user_id

    @property
    def user_id_b36(self):
        """The numeric ID of the user in base 36"""
        return utils.base_10_to_36(self.user_id)

    @property
    def channel_id(self):
        """The numeric ID of the channel who posted the message, if there is one"""
        try:
            #Note: For some reason, channel IDs in messages alone show up as integers in the SSE events
            return int(self["channel_id"])
        except KeyError: #This user is not appearing as a channel and so has no channel ID
            return None

    @property
    def channel_id_b10(self):
        """The ID of the channel who posted the message in base 10"""
        return self.channel_id

    @property
    def channel_id_b36(self):
        """The ID of the channel who posted the message in base 36"""
        if not self.channel_id:
            return
        return utils.base_10_to_36(self.channel_id)

    @property
    def text(self):
        """The text of the message"""
        return self["text"]

    @property
    def user(self):
        """Reference to the user who posted this message"""
        try:
            return self.chat.users[self.user_id]
        except KeyError:
            print(f"ERROR: Message {self.message_id} could not reference user {self.user_id} because chat has no records of them as of yet.")

    @property
    def channel(self):
        """Reference to the channel that posted this message, if there was one"""
        if not self.channel_id:
            return None

        return self.chat.channels[self.channel_id]

    @property
    def is_rant(self):
        """Is this message a rant?"""
        return "rant" in self._jsondata

    @property
    def rant_price_cents(self):
        """The price of the rant, returns 0 if message is not a rant"""
        if not self.is_rant:
            return 0
        return self["rant"]["price_cents"]

    @property
    def rant_duration(self):
        """The duration the rant will show for, returns 0 if message is not a rant"""
        if not self.is_rant:
            return 0
        return self["rant"]["duration"]

    @property
    def rant_expires_on(self):
        """When the rant expires, returns message creation time if message is not a rant"""
        if not self.is_rant:
            return self.time
        return utils.parse_timestamp(self["rant"]["expires_on"])

    @property
    def raid_notification(self):
        """Are we a raid notification? Returns associated JSON data if yes, False if no"""
        if "raid_notification" in self._jsondata:
            return self["raid_notification"]

        return False

channel property

Reference to the channel that posted this message, if there was one

channel_id property

The numeric ID of the channel who posted the message, if there is one

channel_id_b10 property

The ID of the channel who posted the message in base 10

channel_id_b36 property

The ID of the channel who posted the message in base 36

is_rant property

Is this message a rant?

message_id property

The unique numerical ID of the chat message in base 10

message_id_b10 property

The unique numerical ID of the chat message in base 10

message_id_b36 property

The unique numerical ID of the chat message in base 36

raid_notification property

Are we a raid notification? Returns associated JSON data if yes, False if no

rant_duration property

The duration the rant will show for, returns 0 if message is not a rant

rant_expires_on property

When the rant expires, returns message creation time if message is not a rant

rant_price_cents property

The price of the rant, returns 0 if message is not a rant

text property

The text of the message

time property

The time the message was sent on, in seconds since the Epoch UTC

user property

Reference to the user who posted this message

user_id property

The numerical ID of the user who posted the message in base 10

user_id_b10 property

The numeric ID of the user in base 10

user_id_b36 property

The numeric ID of the user in base 36

__eq__(other)

Compare this chat message with another

Parameters:

Name Type Description Default
other (str, ChatAPIMessage)

Object to compare to.

required

Returns:

Name Type Description
Comparison (bool, None)

Did it fit the criteria?

Source code in cocorum/chatapi.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
def __eq__(self, other):
    """Compare this chat message with another

Args:
    other (str, ChatAPIMessage): Object to compare to.

Returns:
    Comparison (bool, None): Did it fit the criteria?
    """

    if isinstance(other, str):
        return self.text == other

    #Check if the other object's text matches our own, if it has such
    if hasattr(other, "text"):
        #Check if the other object's user ID matches our own, if it has one
        if hasattr(other, "user_id"):
            #Check if the other object is a raid notification, if it says
            if hasattr(other, "raid_notification"):
                return (self.user_id, self.text, self.raid_notification) == (other.user_id, other.text, other.raid_notification)

            return (self.user_id, self.text) == (other.user_id, other.text)

        #Check if the other object's username matches our own, if it has one
        if hasattr(other, "username"):
            #Check if the other object is a raid notification, if it says
            if hasattr(other, "raid_notification"):
                return (self.user_id, self.text, self.raid_notification) == (other.user_id, other.text, other.raid_notification)

            return (self.user.username, self.text) == (other.username, other.text)

        #No user identifying attributes, but the text does match
        return self.text == other.text

__init__(jsondata, chat)

A single chat message in the internal chat API

Parameters:

Name Type Description Default
jsondata dict

The JSON data block for the message.

required
chat ChatAPI

The ChatAPI object that spawned us.

required
Source code in cocorum/chatapi.py
255
256
257
258
259
260
261
262
263
264
265
266
267
def __init__(self, jsondata, chat):
    """A single chat message in the internal chat API

Args:
    jsondata (dict): The JSON data block for the message.
    chat (ChatAPI): The ChatAPI object that spawned us.
    """

    super().__init__(jsondata, chat)

    #Set the channel ID of our user if we can
    if self.user:
        self.user._set_channel_id = self.channel_id

__int__()

The chat message in integer (ID) form

Source code in cocorum/chatapi.py
307
308
309
def __int__(self):
    """The chat message in integer (ID) form"""
    return self.message_id

__str__()

The chat message in string form

Source code in cocorum/chatapi.py
303
304
305
def __str__(self):
    """The chat message in string form"""
    return self.text

ChatAPIObj

Bases: JSONObj

Object in the internal chat API

Source code in cocorum/chatapi.py
32
33
34
35
36
37
38
39
40
41
42
43
class ChatAPIObj(JSONObj):
    """Object in the internal chat API"""
    def __init__(self, jsondata, chat):
        """Object in the internal chat API

    Args:
        jsondata (dict): The JSON data block for the object.
        chat (ChatAPI): The ChatAPI object that spawned us.
        """

        JSONObj.__init__(self, jsondata)
        self.chat = chat

__init__(jsondata, chat)

Object in the internal chat API

Parameters:

Name Type Description Default
jsondata dict

The JSON data block for the object.

required
chat ChatAPI

The ChatAPI object that spawned us.

required
Source code in cocorum/chatapi.py
34
35
36
37
38
39
40
41
42
43
def __init__(self, jsondata, chat):
    """Object in the internal chat API

Args:
    jsondata (dict): The JSON data block for the object.
    chat (ChatAPI): The ChatAPI object that spawned us.
    """

    JSONObj.__init__(self, jsondata)
    self.chat = chat

ChatAPIUser

Bases: ChatAPIChatter

User in the internal chat API

Source code in cocorum/chatapi.py
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
class ChatAPIUser(ChatAPIChatter):
    """User in the internal chat API"""
    def __init__(self, jsondata, chat):
        """A user in the internal chat API

    Args:
        jsondata (dict): The JSON data block for the user.
        chat (ChatAPI): The ChatAPI object that spawned us.
        """

        ChatAPIChatter.__init__(self, jsondata, chat)
        self.previous_channel_ids = [] #List of channels the user has appeared as, including the current one
        self._set_channel_id = None #Channel ID set from message

    def __int__(self):
        """The user as an integer (it's ID in base 10)"""
        return self.user_id_b10

    @property
    def user_id(self):
        """The numeric ID of the user in base 10"""
        return int(self["id"])

    @property
    def user_id_b10(self):
        """The numeric ID of the user in base 10"""
        return self.user_id

    @property
    def user_id_b36(self):
        """The numeric ID of the user in base 36"""
        return utils.base_10_to_36(self.user_id)

    @property
    def channel_id(self):
        """The numeric channel ID that the user is appearing with in base 10"""

        #Try to get our channel ID from our own JSON (may be deprecated)
        try:
            new = int(self["channel_id"])

        #Rely on messages to have assigned our channel ID
        except KeyError:
            new = self._set_channel_id

        if new not in self.previous_channel_ids: #Record the appearance of a new chanel appearance, including None
            self.previous_channel_ids.append(new)
        return new

    @property
    def channel_id_b10(self):
        """The numeric channel ID that the user is appearing with in base 10"""
        return self.channel_id

    @property
    def channel_id_b36(self):
        """The numeric channel ID that the user is appearing with in base 36"""
        if not self.channel_id:
            return
        return utils.base_10_to_36(self.channel_id)

    @property
    def is_follower(self):
        """Is this user following the livestreaming channel?"""
        return self["is_follower"]

    @property
    def color(self):
        """The color of our username (RGB tuple)"""
        return tuple(int(self["color"][i : i + 2], 16) for i in range(0, 6, 2))

    @property
    def badges(self):
        """Badges the user has"""
        try:
            return [self.chat.badges[badge_slug] for badge_slug in self["badges"]]

        #User has no badges
        except KeyError:
            return []

badges property

Badges the user has

channel_id property

The numeric channel ID that the user is appearing with in base 10

channel_id_b10 property

The numeric channel ID that the user is appearing with in base 10

channel_id_b36 property

The numeric channel ID that the user is appearing with in base 36

color property

The color of our username (RGB tuple)

is_follower property

Is this user following the livestreaming channel?

user_id property

The numeric ID of the user in base 10

user_id_b10 property

The numeric ID of the user in base 10

user_id_b36 property

The numeric ID of the user in base 36

__init__(jsondata, chat)

A user in the internal chat API

Parameters:

Name Type Description Default
jsondata dict

The JSON data block for the user.

required
chat ChatAPI

The ChatAPI object that spawned us.

required
Source code in cocorum/chatapi.py
64
65
66
67
68
69
70
71
72
73
74
def __init__(self, jsondata, chat):
    """A user in the internal chat API

Args:
    jsondata (dict): The JSON data block for the user.
    chat (ChatAPI): The ChatAPI object that spawned us.
    """

    ChatAPIChatter.__init__(self, jsondata, chat)
    self.previous_channel_ids = [] #List of channels the user has appeared as, including the current one
    self._set_channel_id = None #Channel ID set from message

__int__()

The user as an integer (it's ID in base 10)

Source code in cocorum/chatapi.py
76
77
78
def __int__(self):
    """The user as an integer (it's ID in base 10)"""
    return self.user_id_b10

ChatAPIUserBadge

Bases: ChatAPIObj

A badge of a user

Source code in cocorum/chatapi.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
class ChatAPIUserBadge(ChatAPIObj):
    """A badge of a user"""
    def __init__(self, slug, jsondata, chat):
        """A user badge in the internal chat API

    Args:
        jsondata (dict): The JSON data block for the user badge.
        chat (ChatAPI): The ChatAPI object that spawned us.
        """

        super().__init__(jsondata, chat)
        self.slug = slug #The unique identification for this badge
        self.__icon = None

    def __eq__(self, other):
        """Check if this badge is equal to another

    Args:
        other (str, ChatAPIUserBadge): Object to compare to.

    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """

        #Check if the string is either our slug or our label in any language
        if isinstance(other, str):
            return other in (self.slug, self.label.values())

        #Check if the compared object has the same slug, if it has one
        if hasattr(other, "slug"):
            return self.slug == other.slug

    def __str__(self):
        """The chat user badge in string form"""
        return self.slug

    @property
    def label(self):
        """A dictionary of lang:label pairs"""
        return self["label"]

    @property
    def icon_url(self):
        """The URL of the badge's icon"""
        return static.URI.rumble_base + self["icons"][static.Misc.badge_icon_size]

    @property
    def icon(self):
        """The badge's icon as a bytestring"""
        if not self.__icon: #We never queried the icon before
            response = requests.get(self.icon_url, timeout = static.Delays.request_timeout)
            assert response.status_code == 200, "Status code " + str(response.status_code)

            self.__icon = response.content

        return self.__icon

icon property

The badge's icon as a bytestring

icon_url property

The URL of the badge's icon

label property

A dictionary of lang:label pairs

__eq__(other)

Check if this badge is equal to another

Parameters:

Name Type Description Default
other (str, ChatAPIUserBadge)

Object to compare to.

required

Returns:

Name Type Description
Comparison (bool, None)

Did it fit the criteria?

Source code in cocorum/chatapi.py
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
def __eq__(self, other):
    """Check if this badge is equal to another

Args:
    other (str, ChatAPIUserBadge): Object to compare to.

Returns:
    Comparison (bool, None): Did it fit the criteria?
    """

    #Check if the string is either our slug or our label in any language
    if isinstance(other, str):
        return other in (self.slug, self.label.values())

    #Check if the compared object has the same slug, if it has one
    if hasattr(other, "slug"):
        return self.slug == other.slug

__init__(slug, jsondata, chat)

A user badge in the internal chat API

Parameters:

Name Type Description Default
jsondata dict

The JSON data block for the user badge.

required
chat ChatAPI

The ChatAPI object that spawned us.

required
Source code in cocorum/chatapi.py
198
199
200
201
202
203
204
205
206
207
208
def __init__(self, slug, jsondata, chat):
    """A user badge in the internal chat API

Args:
    jsondata (dict): The JSON data block for the user badge.
    chat (ChatAPI): The ChatAPI object that spawned us.
    """

    super().__init__(jsondata, chat)
    self.slug = slug #The unique identification for this badge
    self.__icon = None

__str__()

The chat user badge in string form

Source code in cocorum/chatapi.py
228
229
230
def __str__(self):
    """The chat user badge in string form"""
    return self.slug

cocorum.servicephp

Service.PHP interactions

Control Rumble via Service.PHP S.D.G.

APIComment

Bases: JSONObj

A comment on a video as returned by a successful attempt to make it

Source code in cocorum/servicephp.py
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
class APIComment(JSONObj):
    """A comment on a video as returned by a successful attempt to make it"""
    def __init__(self, jsondata):
        """A comment on a video as returned by a successful attempt to make it

    Args:
        jsondata (dict): The JSON block for a single comment.
        """

        super().__init__(jsondata)

        #Badges of the user who commented if we have them
        if self.get("comment_user_badges"):
            self.user_badges = {slug : APIUserBadge(slug, data) for slug, data in self["comment_user_badges"].items()}

    def __int__(self):
        """The comment in integer form (its ID)"""
        return self.comment_id

    def __str__(self):
        """The comment as a string (its text)"""
        return self.text

    def __eq__(self, other):
        """Determine if this comment is equal to another

    Args:
        other (int, str, APIComment): Object to compare to.

    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """

        #Check for direct matches first
        if isinstance(other, int):
            return self.comment_id_b10 == other
        if isinstance(other, str):
            return str(self) == other

        #Check for object attributes to match to
        if hasattr(other, "comment_id"):
            return self.comment_id_b10 == utils.ensure_b10(other.comment_id)

        #Check conversion to integer last
        if hasattr(other, "__int__"):
            return self.comment_id_b10 == int(other)

    @property
    def comment_id(self):
        """The numeric ID of the comment"""
        return int(self["comment_id"])

    @property
    def comment_id_b10(self):
        """The base 10 ID of the comment"""
        return self.comment_id

    @property
    def comment_id_b36(self):
        """The base 36 ID of the comment"""
        return utils.base_10_to_36(self.comment_id)

    @property
    def text(self):
        """The text of the comment"""
        return self["comment_text"]

    @property
    def user_display(self):
        """The display name of the user who commented"""
        return self["comment_user_display"]

    @property
    def tree_size(self):
        """TODO"""
        return self["comment_tree_size"]

comment_id property

The numeric ID of the comment

comment_id_b10 property

The base 10 ID of the comment

comment_id_b36 property

The base 36 ID of the comment

text property

The text of the comment

tree_size property

TODO

user_display property

The display name of the user who commented

__eq__(other)

Determine if this comment is equal to another

Parameters:

Name Type Description Default
other (int, str, APIComment)

Object to compare to.

required

Returns:

Name Type Description
Comparison (bool, None)

Did it fit the criteria?

Source code in cocorum/servicephp.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def __eq__(self, other):
    """Determine if this comment is equal to another

Args:
    other (int, str, APIComment): Object to compare to.

Returns:
    Comparison (bool, None): Did it fit the criteria?
    """

    #Check for direct matches first
    if isinstance(other, int):
        return self.comment_id_b10 == other
    if isinstance(other, str):
        return str(self) == other

    #Check for object attributes to match to
    if hasattr(other, "comment_id"):
        return self.comment_id_b10 == utils.ensure_b10(other.comment_id)

    #Check conversion to integer last
    if hasattr(other, "__int__"):
        return self.comment_id_b10 == int(other)

__init__(jsondata)

A comment on a video as returned by a successful attempt to make it

Parameters:

Name Type Description Default
jsondata dict

The JSON block for a single comment.

required
Source code in cocorum/servicephp.py
73
74
75
76
77
78
79
80
81
82
83
84
def __init__(self, jsondata):
    """A comment on a video as returned by a successful attempt to make it

Args:
    jsondata (dict): The JSON block for a single comment.
    """

    super().__init__(jsondata)

    #Badges of the user who commented if we have them
    if self.get("comment_user_badges"):
        self.user_badges = {slug : APIUserBadge(slug, data) for slug, data in self["comment_user_badges"].items()}

__int__()

The comment in integer form (its ID)

Source code in cocorum/servicephp.py
86
87
88
def __int__(self):
    """The comment in integer form (its ID)"""
    return self.comment_id

__str__()

The comment as a string (its text)

Source code in cocorum/servicephp.py
90
91
92
def __str__(self):
    """The comment as a string (its text)"""
    return self.text

APIContentVotes

Bases: JSONObj

Votes made on content

Source code in cocorum/servicephp.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
class APIContentVotes(JSONObj):
    """Votes made on content"""
    def __int__(self):
        """The integer form of the content votes"""
        return self.score

    def __str__(self):
        """The string form of the content votes"""
        return self.score_formatted

    def __eq__(self, other):
        """Determine if this content votes is equal to another

    Args:
        other (int, str, APIContentVotes): Object to compare to.

    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """

        #Check for direct matches first
        if isinstance(other, int):
            return self.score == other
        if isinstance(other, str):
            return other in (str(self.score), self.score_formatted)

        #Check for object attributes to match to
        if hasattr(other, "score"):
            #if hasattr(other, "content_id") and hasattr(other, "content_type"):
            #    return self.score, self.content_id, self.content_type == other.score, other.content_id, other.content_type
            return self.score == other.score

        #Check conversion to integer last
        if hasattr(other, "__int__"):
            return self.score == int(other)

    @property
    def num_votes_up(self):
        """Upvotes on the content"""
        return self._jsondata.get("num_votes_up", 0)

    @property
    def num_votes_down(self):
        """Downvotes on the content"""
        return self._jsondata.get("num_votes_down", 0)

    @property
    def score(self):
        """Summed score of the content"""
        return self["score"]

    @property
    def votes(self):
        """The total number of votes on the content"""
        if not self["votes"]:
            return 0
        return self["votes"]

    @property
    def num_votes_up_formatted(self):
        """The upvotes on the content, formatted into a string"""
        return self._jsondata.get("num_votes_up_formatted", "0")

    @property
    def num_votes_down_formatted(self):
        """The downvotes on the content, formatted into a string"""
        return self._jsondata.get("num_votes_down_formatted", "0")

    @property
    def score_formatted(self):
        """The total votes on the content, formatted into a string"""
        return self._jsondata.get("score_formatted", "0")

    @property
    def content_type(self):
        """The type of content being voted on"""
        return self["content_type"]

    @property
    def content_id(self):
        """The numerical ID of the content being voted on"""
        return self["content_id"]

content_id property

The numerical ID of the content being voted on

content_type property

The type of content being voted on

num_votes_down property

Downvotes on the content

num_votes_down_formatted property

The downvotes on the content, formatted into a string

num_votes_up property

Upvotes on the content

num_votes_up_formatted property

The upvotes on the content, formatted into a string

score property

Summed score of the content

score_formatted property

The total votes on the content, formatted into a string

votes property

The total number of votes on the content

__eq__(other)

Determine if this content votes is equal to another

Parameters:

Name Type Description Default
other (int, str, APIContentVotes)

Object to compare to.

required

Returns:

Name Type Description
Comparison (bool, None)

Did it fit the criteria?

Source code in cocorum/servicephp.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def __eq__(self, other):
    """Determine if this content votes is equal to another

Args:
    other (int, str, APIContentVotes): Object to compare to.

Returns:
    Comparison (bool, None): Did it fit the criteria?
    """

    #Check for direct matches first
    if isinstance(other, int):
        return self.score == other
    if isinstance(other, str):
        return other in (str(self.score), self.score_formatted)

    #Check for object attributes to match to
    if hasattr(other, "score"):
        #if hasattr(other, "content_id") and hasattr(other, "content_type"):
        #    return self.score, self.content_id, self.content_type == other.score, other.content_id, other.content_type
        return self.score == other.score

    #Check conversion to integer last
    if hasattr(other, "__int__"):
        return self.score == int(other)

__int__()

The integer form of the content votes

Source code in cocorum/servicephp.py
150
151
152
def __int__(self):
    """The integer form of the content votes"""
    return self.score

__str__()

The string form of the content votes

Source code in cocorum/servicephp.py
154
155
156
def __str__(self):
    """The string form of the content votes"""
    return self.score_formatted

APIPlaylist

Bases: JSONObj

Playlist as returned by the API

Source code in cocorum/servicephp.py
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
class APIPlaylist(JSONObj):
    """Playlist as returned by the API"""
    def __init__(self, jsondata):
        """Playlist as returned by the API.

    Args:
        jsondata (dict): The JSON data block of a playlist.
        """

        super().__init__(jsondata)
        self.user = APIUser(jsondata["user"])

    def __int__(self):
        """The playlist as an integer (it's ID in base 10)"""
        return self.playlist_id_b10

    def __str__(self):
        """The playlist as a string (it's ID in base 36)"""
        return self.playlist_id_b36

    def __eq__(self, other):
        """Determine if this playlist is equal to another.

    Args:
        other (int, str, APIPlaylist): Object to compare to.

    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """

        #Check for direct matches first
        if isinstance(other, int):
            return self.playlist_id_b10 == other
        if isinstance(other, str):
            return str(other) == self.playlist_id_b36

        #Check for object attributes to match to
        if hasattr(other, "playlist_id"):
            return self.playlist_id_b10 == utils.ensure_b10(other.playlist_id)

        #Check conversion to integer last, in case another ID or something happens to match
        if hasattr(other, "__int__"):
            return self.playlist_id_b10 == int(other)

    @property
    def playlist_id(self):
        """The numeric playlist ID in base 36"""
        return self["id"]

    @property
    def playlist_id_b36(self):
        """The numeric ID of the playlist in base 36"""
        return self.playlist_id

    @property
    def playlist_id_b10(self):
        """The numeric ID of the playlist in base 10"""
        return utils.base_36_to_10(self.playlist_id)

    @property
    def title(self):
        """The title of the playlist"""
        return self["title"]

    @property
    def description(self):
        """The description of the playlist"""
        return self["description"]

    @property
    def visibility(self):
        """The visibility of the playlist"""
        return self["visibility"]

    @property
    def url(self):
        """The URL of the playlist"""
        return self["url"]

    @property
    def channel(self):
        """The channel the playlist is under, can be None"""
        return self["channel"]

    @property
    def created_on(self):
        """The time the playlist was created in seconds since epoch"""
        return utils.parse_timestamp(self["created_on"])

    @property
    def updated_on(self):
        """The time the playlist was last updated in seconds since epoch"""
        return utils.parse_timestamp(self["updated_on"])

    @property
    def permissions(self):
        """The permissions the ServicePHP user has on this playlist"""
        return self["permissions"]

    @property
    def num_items(self):
        """The number of items in the playlist"""
        return self["num_items"]

    @property
    def is_following(self):
        """TODO -> Bool"""
        return self["is_following"]

    @property
    def items(self):
        """The items of the playlist. TODO"""
        return self["items"]

    @property
    def extra(self):
        """TODO -> None, unknown"""
        return self["extra"]

channel property

The channel the playlist is under, can be None

created_on property

The time the playlist was created in seconds since epoch

description property

The description of the playlist

extra property

TODO -> None, unknown

is_following property

TODO -> Bool

items property

The items of the playlist. TODO

num_items property

The number of items in the playlist

permissions property

The permissions the ServicePHP user has on this playlist

playlist_id property

The numeric playlist ID in base 36

playlist_id_b10 property

The numeric ID of the playlist in base 10

playlist_id_b36 property

The numeric ID of the playlist in base 36

title property

The title of the playlist

updated_on property

The time the playlist was last updated in seconds since epoch

url property

The URL of the playlist

visibility property

The visibility of the playlist

__eq__(other)

Determine if this playlist is equal to another.

Parameters:

Name Type Description Default
other (int, str, APIPlaylist)

Object to compare to.

required

Returns:

Name Type Description
Comparison (bool, None)

Did it fit the criteria?

Source code in cocorum/servicephp.py
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
def __eq__(self, other):
    """Determine if this playlist is equal to another.

Args:
    other (int, str, APIPlaylist): Object to compare to.

Returns:
    Comparison (bool, None): Did it fit the criteria?
    """

    #Check for direct matches first
    if isinstance(other, int):
        return self.playlist_id_b10 == other
    if isinstance(other, str):
        return str(other) == self.playlist_id_b36

    #Check for object attributes to match to
    if hasattr(other, "playlist_id"):
        return self.playlist_id_b10 == utils.ensure_b10(other.playlist_id)

    #Check conversion to integer last, in case another ID or something happens to match
    if hasattr(other, "__int__"):
        return self.playlist_id_b10 == int(other)

__init__(jsondata)

Playlist as returned by the API.

Parameters:

Name Type Description Default
jsondata dict

The JSON data block of a playlist.

required
Source code in cocorum/servicephp.py
329
330
331
332
333
334
335
336
337
def __init__(self, jsondata):
    """Playlist as returned by the API.

Args:
    jsondata (dict): The JSON data block of a playlist.
    """

    super().__init__(jsondata)
    self.user = APIUser(jsondata["user"])

__int__()

The playlist as an integer (it's ID in base 10)

Source code in cocorum/servicephp.py
339
340
341
def __int__(self):
    """The playlist as an integer (it's ID in base 10)"""
    return self.playlist_id_b10

__str__()

The playlist as a string (it's ID in base 36)

Source code in cocorum/servicephp.py
343
344
345
def __str__(self):
    """The playlist as a string (it's ID in base 36)"""
    return self.playlist_id_b36

APIUser

Bases: JSONObj

User data as returned by the API

Source code in cocorum/servicephp.py
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
class APIUser(JSONObj):
    """User data as returned by the API"""
    def __init__(self, jsondata):
        """User data as returned by the API.

    Args:
        jsondata (dict): The JSON data block of a single user.
        """

        super().__init__(jsondata)

        #Our profile picture data
        self.__picture = None

    def __int__(self):
        """The user as an integer (it's ID in base 10)"""
        return self.user_id_b10

    def __eq__(self, other):
        """Determine if this user is equal to another.

    Args:
        other (int, str, APIUser): Object to compare to.

    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """

        #Check for direct matches first
        if isinstance(other, int):
            return self.user_id_b10 == other
        if isinstance(other, str):
            return str(other) in (self.user_id_b36, self.username)

        #Check for object attributes to match to
        if hasattr(other, "user_id"):
            return self.user_id_b10 == utils.ensure_b10(other.user_id)

        #Check conversion to integer last, in case another ID or something happens to match
        if hasattr(other, "__int__"):
            return self.user_id_b10 == int(other)

    @property
    def user_id(self):
        """The numeric ID of the user in base 10"""
        return self["id"]

    @property
    def user_id_b10(self):
        """The numeric ID of the user in base 10"""
        return self.user_id

    @property
    def user_id_b36(self):
        """The numeric ID of the user in base 36"""
        return utils.base_10_to_36(self.user_id)

    @property
    def username(self):
        """The username of the user"""
        return self["username"]

    @property
    def picture_url(self):
        """The URL of the user's profile picture"""
        return self["picture"]

    @property
    def picture(self):
        """The user's profile picture as a bytes string"""
        if not self.picture_url: #The profile picture is blank
            return b''

        if not self.__picture: #We never queried the profile pic before
            response = requests.get(self.picture_url, timeout = static.Delays.request_timeout)
            assert response.status_code == 200, "Status code " + str(response.status_code)

            self.__picture = response.content

        return self.__picture

    @property
    def verified_badge(self):
        """Is the user verified?"""
        return self["verified_badge"]

    @property
    def followers(self):
        """The number of followers this user has"""
        return self["followers"]

    @property
    def followed(self):
        """TODO -> Bool"""
        return self["followed"]

followed property

TODO -> Bool

followers property

The number of followers this user has

picture property

The user's profile picture as a bytes string

picture_url property

The URL of the user's profile picture

user_id property

The numeric ID of the user in base 10

user_id_b10 property

The numeric ID of the user in base 10

user_id_b36 property

The numeric ID of the user in base 36

username property

The username of the user

verified_badge property

Is the user verified?

__eq__(other)

Determine if this user is equal to another.

Parameters:

Name Type Description Default
other (int, str, APIUser)

Object to compare to.

required

Returns:

Name Type Description
Comparison (bool, None)

Did it fit the criteria?

Source code in cocorum/servicephp.py
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def __eq__(self, other):
    """Determine if this user is equal to another.

Args:
    other (int, str, APIUser): Object to compare to.

Returns:
    Comparison (bool, None): Did it fit the criteria?
    """

    #Check for direct matches first
    if isinstance(other, int):
        return self.user_id_b10 == other
    if isinstance(other, str):
        return str(other) in (self.user_id_b36, self.username)

    #Check for object attributes to match to
    if hasattr(other, "user_id"):
        return self.user_id_b10 == utils.ensure_b10(other.user_id)

    #Check conversion to integer last, in case another ID or something happens to match
    if hasattr(other, "__int__"):
        return self.user_id_b10 == int(other)

__init__(jsondata)

User data as returned by the API.

Parameters:

Name Type Description Default
jsondata dict

The JSON data block of a single user.

required
Source code in cocorum/servicephp.py
233
234
235
236
237
238
239
240
241
242
243
def __init__(self, jsondata):
    """User data as returned by the API.

Args:
    jsondata (dict): The JSON data block of a single user.
    """

    super().__init__(jsondata)

    #Our profile picture data
    self.__picture = None

__int__()

The user as an integer (it's ID in base 10)

Source code in cocorum/servicephp.py
245
246
247
def __int__(self):
    """The user as an integer (it's ID in base 10)"""
    return self.user_id_b10

APIUserBadge

Bases: JSONObj

A badge of a user as returned by the API

Source code in cocorum/servicephp.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
class APIUserBadge(JSONObj):
    """A badge of a user as returned by the API"""
    def __init__(self, slug, jsondata):
        """A badge of a user as returned by the API.

    Args:
        slug (str): The string identifier of the badge.
        jsondata (dict): The JSON data block of the badge.
        """

        super().__init__(jsondata)
        self.slug = slug #The unique identification for this badge
        self.__icon = None

    def __eq__(self, other):
        """Check if this badge is equal to another.

    Args:
        other (str, APIUserBadge): Object to compare to.

    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """

        #Check if the string is either our slug or our label in any language
        if isinstance(other, str):
            return other in (self.slug, self.label.values())

        #Check if the compared object has the same slug, if it has one
        if hasattr(other, "slug"):
            return self.slug == other.slug

    def __str__(self):
        """The chat user badge in string form (its slug)"""
        return self.slug

    @property
    def label(self):
        """A dictionary of lang:label pairs"""
        return self["label"]

    @property
    def icon_url(self):
        """The URL of the badge's icon"""
        return static.URI.rumble_base + self["icons"][static.Misc.badge_icon_size]

    @property
    def icon(self):
        """The badge's icon as a bytestring"""
        if not self.__icon: #We never queried the icon before
            response = requests.get(self.icon_url, timeout = static.Delays.request_timeout)
            assert response.status_code == 200, "Status code " + str(response.status_code)

            self.__icon = response.content

        return self.__icon

icon property

The badge's icon as a bytestring

icon_url property

The URL of the badge's icon

label property

A dictionary of lang:label pairs

__eq__(other)

Check if this badge is equal to another.

Parameters:

Name Type Description Default
other (str, APIUserBadge)

Object to compare to.

required

Returns:

Name Type Description
Comparison (bool, None)

Did it fit the criteria?

Source code in cocorum/servicephp.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def __eq__(self, other):
    """Check if this badge is equal to another.

Args:
    other (str, APIUserBadge): Object to compare to.

Returns:
    Comparison (bool, None): Did it fit the criteria?
    """

    #Check if the string is either our slug or our label in any language
    if isinstance(other, str):
        return other in (self.slug, self.label.values())

    #Check if the compared object has the same slug, if it has one
    if hasattr(other, "slug"):
        return self.slug == other.slug

__init__(slug, jsondata)

A badge of a user as returned by the API.

Parameters:

Name Type Description Default
slug str

The string identifier of the badge.

required
jsondata dict

The JSON data block of the badge.

required
Source code in cocorum/servicephp.py
16
17
18
19
20
21
22
23
24
25
26
def __init__(self, slug, jsondata):
    """A badge of a user as returned by the API.

Args:
    slug (str): The string identifier of the badge.
    jsondata (dict): The JSON data block of the badge.
    """

    super().__init__(jsondata)
    self.slug = slug #The unique identification for this badge
    self.__icon = None

__str__()

The chat user badge in string form (its slug)

Source code in cocorum/servicephp.py
46
47
48
def __str__(self):
    """The chat user badge in string form (its slug)"""
    return self.slug

ServicePHP

Interact with Rumble's service.php API

Source code in cocorum/servicephp.py
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
class ServicePHP:
    """Interact with Rumble's service.php API"""
    def __init__(self, username: str, password: str = None, session = None):
        """Interact with Rumble's service.php API.

    Args:
        username (str): The username we will be under.
        password (str): The password to use at login.
            Defaults to using the session token/cookie instead.
        session (str, dict): The session token or cookie dict to authenticate with.
            Defaults to using the password instead.
            """

        #Save the username
        self.username = username

        #Session is the token directly
        if isinstance(session, str):
            self.session_cookie = {static.Misc.session_token_key, session}

        #Session is a cookie dict
        elif isinstance(session, dict):
            assert session.get(static.Misc.session_token_key), f"Session cookie dict must have '{static.Misc.session_token_key}' as key."
            self.session_cookie = session

        #Session was passed but it is not anything we can use
        elif session is not None:
            raise ValueError(f"Session must be a token str or cookie dict, got {type(session)}")

        #Session was not passed, but credentials were
        elif username and password:
            self.session_cookie = self.login(username, password)

        #Neither session nor credentials were passed:
        else:
            raise ValueError("Must pass either userame and password, or a session token")

        assert utils.test_session_cookie(self.session_cookie), "Session cookie is invalid."

        #Stored ID of the logged in user
        self.__user_id = None

    @property
    def user_id(self):
        """The numeric ID of the logged in user in base 10"""
        #We do not have a user ID, extract it from the unread notifications response
        if self.__user_id is None:
            j = self.sphp_request(
                "user.has_unread_notifications",
                method = "GET",
                ).json()
            self.__user_id = utils.base_36_to_10(j["user"]["id"].removeprefix("_"))

        return self.__user_id

    @property
    def user_id_b10(self):
        """The numeric ID of the logged in user in base 10"""
        return self.user_id

    @property
    def user_id_b36(self):
        """The numeric ID of the logged in user in base 36"""
        return utils.base_10_to_36(self.user_id)

    def sphp_request(self, service_name: str, data: dict = {}, additional_params: dict = {}, logged_in = True, method = "POST"):
        """Make a request to Service.PHP with common settings
        service_name: The name parameter of the specific PHP service
        data: Form data
        additional_params: Any additional query string parameters
        logged_in: The request should use the session cookie"""
        params = {"name" : service_name}
        params.update(additional_params)
        r = requests.request(
                method,
                static.URI.servicephp,
                params = params,
                data = data,
                headers = static.RequestHeaders.user_agent,
                cookies = self.session_cookie if logged_in else None,
                timeout = static.Delays.request_timeout,
                )
        assert r.status_code == 200, f"Service.PHP request for {service_name} failed: {r}\n{r.text}"
        #If the request json has a data -> success value, make sure it is True
        d = r.json().get("data")
        if isinstance(d, dict):
            assert d.get("success", True), f"Service.PHP request for {service_name} failed: \n{r.text}"
        #Data was not a dict but was not empty
        elif d:
            print(f"Service.PHP request for {service_name} did not fail but returned unknown data type {type(d)}: {d}")

        return r

    def login(self, username: str, password: str):
        """Log in to Rumble

    Args:
        username (str): Username to sign in with.
        password (str): Password to sign in with.

    Returns:
        Cookie (dict): Cookie dict to be passed with requests, which authenticates them.
        """

        #Get salts
        r = self.sphp_request(
                "user.get_salts",
                data = {"username": username},
                logged_in = False,
                )
        salts = r.json()["data"]["salts"]

        #Get session token
        r = self.sphp_request(
                "user.login",
                data = {
                    "username": username,

                    #Hash the password using the salts
                    "password_hashes": ",".join(utils.calc_password_hashes(password, salts)),
                    },
                logged_in = False,
                )
        j = r.json()
        session_token = j["data"]["session"]
        assert session_token, f"Login failed: No token returned\n{r.json()}"

        return {static.Misc.session_token_key: session_token}

    def chat_pin(self, stream_id, message, unpin: bool = False):
        """Pin or unpin a message in a chat.

    Args:
        stream_id (int, str): ID of the stream in base 10 or 36.
        message (int): Converting this to int must return a chat message ID.
        unpin (bool): If true, unpins a message instead of pinning it.
        """

        self.sphp_request(
            f"chat.message.{"un" * unpin}pin",
            data = {
                "video_id": utils.ensure_b10(stream_id),
                "message_id": int(message),
                },
            )

    def mute_user(self, username: str, is_channel: bool = False, video: int = None, duration: int = None, total: bool = False):
        """Mute a user or channel by name.

    Args:
        username (str): The user to mute.
        is_channel (bool): Is this a channel name rather than a username?
        video (int): The video to mute the user on.
            Defaults to None.
        duration (int): How long the user will be muted for, in seconds.
            Defaults to infinity.
        total (bool): Is this a mute across all videos?
            Defaults to False, requires video if False.
            """

        self.sphp_request(
            "moderation.mute",
            data = {
                "user_to_mute": username,
                "entity_type": ("user", "channel")[is_channel],
                "video": int(video),
                "duration": duration,
                "type": ("video", "total")[total],
                },
            )

    def unmute_user(self, record_id: int):
        """Unmute a user.

    Args:
        record_id: The numeric ID of the mute record to undo.
        """

        self.sphp_request(
            "moderation.unmute",
            data = {
                "record_id" : record_id,
                }
            )

    def _is_comment_elem(self, e):
        """Check if a beautifulsoup element is a comment.

    Args:
        e (bs4.Tag): The BeautifulSoup element to test.

    Returns:
        Result (bool): Did the element fit the criteria for being a comment?
        """

        return e.name == "li" and "comment-item" in e.get("class") and "comments-create" not in e.get("class")

    def comment_list(self, video_id):
        """Get the list of comments under a video.

    Args:
        video_id (str, int): The numeric ID of a video in base 10 or 36.

    Returns:
        Comments (list): A list of scraping.HTMLComment objects.
        """

        r = self.sphp_request(
            "comment.list",
            additional_params = {
                "video" : utils.ensure_b36(video_id),
                },
            method = "GET",
            )
        soup = bs4.BeautifulSoup(r.json()["html"], features = "html.parser")
        comment_elems = soup.find_all(self._is_comment_elem)
        return [scraping.HTMLComment(e) for e in comment_elems]

    def comment_add(self, video_id, comment: str, reply_id: int = 0):
        """Post a comment on a video.

    Args:
        video_id (int, str): The numeric ID of a video / stream in base 10 or 36.
        comment (str): What to say.
        reply_id (int): The ID of the comment to reply to.
            Defaults to zero (don't reply to anybody).

    Returns:
        Comment (APIComment): The comment, as parsed from the response data.
        """

        r = self.sphp_request(
                "comment.add",
                data = {
                    "video": int(utils.ensure_b10(video_id)),
                    "reply_id": int(reply_id),
                    "comment": str(comment),
                    "target": "comment-create-1",
                    },
                )
        return APIComment(r.json()["data"])

    def comment_pin(self, comment_id: int, unpin: bool = False):
        """Pin or unpin a comment by ID.

    Args:
        comment_id (int): The numeric ID of the comment to pin/unpin.
        unpin (bool): If true, unpins instead of pinning comment.
        """

        self.sphp_request(
            f"comment.{"un" * unpin}pin",
            data = {"comment_id": int(comment_id)},
            )

    def comment_delete(self, comment_id: int):
        """Delete a comment by ID.

    Args:
        comment_id (int): The numeric ID of the comment to delete.
        """

        self.sphp_request(
            "comment.delete",
            data = {"comment_id": int(comment_id)},
            )

    def comment_restore(self, comment_id: int):
        """Restore a deleted comment by ID.

    Args:
        comment_id (int): The numeric ID of the comment to restore.
        """

        r = self.sphp_request(
            "comment.restore",
            data = {"comment_id": int(comment_id)},
            )
        return APIComment(r.json()["data"])

    def rumbles(self, vote: int, item_id, item_type: int):
        """Post a like or dislike.

    Args:
        vote (int): -1, 0, or 1 (0 means clear vote).
        item_id (int): The numeric ID of whatever we are liking or disliking.
        item_type (int): 1 for video, 2 for comment.
        """

        r = self.sphp_request(
            "user.rumbles",
            data = {
                "type" : int(item_type),
                "id" : utils.ensure_b10(item_id),
                "vote" : int(vote),
                },
            )
        return APIContentVotes(r.json()["data"])

    def get_video_url(self, video_id):
        """Get the URL of a Rumble video.

    Args:
        video_id (int, str): The numeric ID of the video.

    Returns:
        URL (str): The URL of the video.
        """

        r = self.sphp_request(
            "media.share",
            additional_params = {
                "video" : utils.ensure_b36(video_id),
                "start" : 0,
                },
            method = "GET",
            )
        soup = bs4.BeautifulSoup(r.json()["html"], features = "html.parser")
        elem = soup.find("div", attrs = {"class" : "fb-share-button share-fb"})
        return elem.attrs["data-url"]

    def playlist_add_video(self, playlist_id: str, video_id):
        """Add a video to a playlist.

    Args:
        playlist_id (str): The numeric ID of the playlist in base 36.
        video_id (int, str): The numeric ID of the video to add, in base 10 or 36.
        """

        print(self.sphp_request(
            "playlist.add_video",
            data = {
                "playlist_id": str(playlist_id),
                "video_id": utils.ensure_b10(video_id),
                }
            ).text)

    def playlist_delete_video(self, playlist_id: str, video_id: int):
        """Remove a video from a playlist.

    Args:
        playlist_id (str): The numeric ID of the playlist in base 36.
        video_id (int, str): The numeric ID of the video to remove, in base 10 or 36.
        """

        print(self.sphp_request(
            "playlist.delete_video",
            data = {
                "playlist_id": str(playlist_id),
                "video_id": utils.ensure_b10(video_id),
                }
            ).text)

    def playlist_add(self, title: str, description: str = "", visibility: str = "public", channel_id = None):
        """Create a new playlist.

    Args:
        title (str): The title of the playlist.
        description (str): Describe the playlist.
            Defaults to nothing.
        visibility (str): Set to public, unlisted, or private via string.
            Defaults to 'public'.
        channel_id (int, str): The ID of the channel to create the playlist under.
            Defaults to none.

    Returns:
        Playlist (APIPlaylist): The playlist as parsed from the response data.
        """

        r = self.sphp_request(
            "playlist.add",
            data = {
                "title": str(title),
                "description": str(description),
                "visibility": str(visibility),
                "channel_id": str(utils.ensure_b10(channel_id)) if channel_id else None,
            }
        )
        return APIPlaylist(r.json()["data"])

    def playlist_edit(self, playlist_id: str, title: str, description: str = "", visibility: str = "public", channel_id = None):
        """Edit the details of an existing playlist

    Args:
        playlist_id (str): The numeric ID of the playlist to edit in base 36.
        title (str): The title of the playlist.
        description (str): Describe the playlist.
            Defaults to nothing.
        visibility (str): Set to public, unlisted, or private via string.
            Defaults to 'public'.
        channel_id (int, str): The ID of the channel to create the playlist under.
            Defaults to none.

    Returns:
        Playlist (APIPlaylist): The playlist as parsed from the response data.
        """

        r = self.sphp_request(
            "playlist.edit",
            data = {
                "title": str(title),
                "description": str(description),
                "visibility": str(visibility),
                "channel_id": str(utils.ensure_b10(channel_id)) if channel_id else None,
                "playlist_id": utils.ensure_b36(playlist_id),
            }
        )
        return APIPlaylist(r.json()["data"])

    def playlist_delete(self, playlist_id: str):
        """Delete a playlist.

    Args:
        playlist_id (str): The numeric ID of the playlist to delete in base 36.
        """

        print(self.sphp_request(
            "playlist.delete",
            data = {"playlist_id" : utils.ensure_b36(playlist_id)},
            ).text)

    def raid_confirm(self, stream_id):
        """Confirm a raid, previously set up by command.

    Args:
        stream_id (int, str): The numeric ID of the stream to confirm the raid from, in base 10 or 36.
        """

        self.sphp_request(
            "raid.confirm",
            data = {"video_id" : utils.ensure_b10(stream_id)},
            )

user_id property

The numeric ID of the logged in user in base 10

user_id_b10 property

The numeric ID of the logged in user in base 10

user_id_b36 property

The numeric ID of the logged in user in base 36

__init__(username, password=None, session=None)

Interact with Rumble's service.php API.

Parameters:

Name Type Description Default
username str

The username we will be under.

required
password str

The password to use at login. Defaults to using the session token/cookie instead.

None
session (str, dict)

The session token or cookie dict to authenticate with. Defaults to using the password instead.

None
Source code in cocorum/servicephp.py
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
def __init__(self, username: str, password: str = None, session = None):
    """Interact with Rumble's service.php API.

Args:
    username (str): The username we will be under.
    password (str): The password to use at login.
        Defaults to using the session token/cookie instead.
    session (str, dict): The session token or cookie dict to authenticate with.
        Defaults to using the password instead.
        """

    #Save the username
    self.username = username

    #Session is the token directly
    if isinstance(session, str):
        self.session_cookie = {static.Misc.session_token_key, session}

    #Session is a cookie dict
    elif isinstance(session, dict):
        assert session.get(static.Misc.session_token_key), f"Session cookie dict must have '{static.Misc.session_token_key}' as key."
        self.session_cookie = session

    #Session was passed but it is not anything we can use
    elif session is not None:
        raise ValueError(f"Session must be a token str or cookie dict, got {type(session)}")

    #Session was not passed, but credentials were
    elif username and password:
        self.session_cookie = self.login(username, password)

    #Neither session nor credentials were passed:
    else:
        raise ValueError("Must pass either userame and password, or a session token")

    assert utils.test_session_cookie(self.session_cookie), "Session cookie is invalid."

    #Stored ID of the logged in user
    self.__user_id = None

chat_pin(stream_id, message, unpin=False)

Pin or unpin a message in a chat.

Parameters:

Name Type Description Default
stream_id (int, str)

ID of the stream in base 10 or 36.

required
message int

Converting this to int must return a chat message ID.

required
unpin bool

If true, unpins a message instead of pinning it.

False
Source code in cocorum/servicephp.py
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
def chat_pin(self, stream_id, message, unpin: bool = False):
    """Pin or unpin a message in a chat.

Args:
    stream_id (int, str): ID of the stream in base 10 or 36.
    message (int): Converting this to int must return a chat message ID.
    unpin (bool): If true, unpins a message instead of pinning it.
    """

    self.sphp_request(
        f"chat.message.{"un" * unpin}pin",
        data = {
            "video_id": utils.ensure_b10(stream_id),
            "message_id": int(message),
            },
        )

comment_add(video_id, comment, reply_id=0)

Post a comment on a video.

Parameters:

Name Type Description Default
video_id (int, str)

The numeric ID of a video / stream in base 10 or 36.

required
comment str

What to say.

required
reply_id int

The ID of the comment to reply to. Defaults to zero (don't reply to anybody).

0

Returns:

Name Type Description
Comment APIComment

The comment, as parsed from the response data.

Source code in cocorum/servicephp.py
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
def comment_add(self, video_id, comment: str, reply_id: int = 0):
    """Post a comment on a video.

Args:
    video_id (int, str): The numeric ID of a video / stream in base 10 or 36.
    comment (str): What to say.
    reply_id (int): The ID of the comment to reply to.
        Defaults to zero (don't reply to anybody).

Returns:
    Comment (APIComment): The comment, as parsed from the response data.
    """

    r = self.sphp_request(
            "comment.add",
            data = {
                "video": int(utils.ensure_b10(video_id)),
                "reply_id": int(reply_id),
                "comment": str(comment),
                "target": "comment-create-1",
                },
            )
    return APIComment(r.json()["data"])

comment_delete(comment_id)

Delete a comment by ID.

Parameters:

Name Type Description Default
comment_id int

The numeric ID of the comment to delete.

required
Source code in cocorum/servicephp.py
701
702
703
704
705
706
707
708
709
710
711
def comment_delete(self, comment_id: int):
    """Delete a comment by ID.

Args:
    comment_id (int): The numeric ID of the comment to delete.
    """

    self.sphp_request(
        "comment.delete",
        data = {"comment_id": int(comment_id)},
        )

comment_list(video_id)

Get the list of comments under a video.

Parameters:

Name Type Description Default
video_id (str, int)

The numeric ID of a video in base 10 or 36.

required

Returns:

Name Type Description
Comments list

A list of scraping.HTMLComment objects.

Source code in cocorum/servicephp.py
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
def comment_list(self, video_id):
    """Get the list of comments under a video.

Args:
    video_id (str, int): The numeric ID of a video in base 10 or 36.

Returns:
    Comments (list): A list of scraping.HTMLComment objects.
    """

    r = self.sphp_request(
        "comment.list",
        additional_params = {
            "video" : utils.ensure_b36(video_id),
            },
        method = "GET",
        )
    soup = bs4.BeautifulSoup(r.json()["html"], features = "html.parser")
    comment_elems = soup.find_all(self._is_comment_elem)
    return [scraping.HTMLComment(e) for e in comment_elems]

comment_pin(comment_id, unpin=False)

Pin or unpin a comment by ID.

Parameters:

Name Type Description Default
comment_id int

The numeric ID of the comment to pin/unpin.

required
unpin bool

If true, unpins instead of pinning comment.

False
Source code in cocorum/servicephp.py
688
689
690
691
692
693
694
695
696
697
698
699
def comment_pin(self, comment_id: int, unpin: bool = False):
    """Pin or unpin a comment by ID.

Args:
    comment_id (int): The numeric ID of the comment to pin/unpin.
    unpin (bool): If true, unpins instead of pinning comment.
    """

    self.sphp_request(
        f"comment.{"un" * unpin}pin",
        data = {"comment_id": int(comment_id)},
        )

comment_restore(comment_id)

Restore a deleted comment by ID.

Parameters:

Name Type Description Default
comment_id int

The numeric ID of the comment to restore.

required
Source code in cocorum/servicephp.py
713
714
715
716
717
718
719
720
721
722
723
724
def comment_restore(self, comment_id: int):
    """Restore a deleted comment by ID.

Args:
    comment_id (int): The numeric ID of the comment to restore.
    """

    r = self.sphp_request(
        "comment.restore",
        data = {"comment_id": int(comment_id)},
        )
    return APIComment(r.json()["data"])

get_video_url(video_id)

Get the URL of a Rumble video.

Parameters:

Name Type Description Default
video_id (int, str)

The numeric ID of the video.

required

Returns:

Name Type Description
URL str

The URL of the video.

Source code in cocorum/servicephp.py
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
def get_video_url(self, video_id):
    """Get the URL of a Rumble video.

Args:
    video_id (int, str): The numeric ID of the video.

Returns:
    URL (str): The URL of the video.
    """

    r = self.sphp_request(
        "media.share",
        additional_params = {
            "video" : utils.ensure_b36(video_id),
            "start" : 0,
            },
        method = "GET",
        )
    soup = bs4.BeautifulSoup(r.json()["html"], features = "html.parser")
    elem = soup.find("div", attrs = {"class" : "fb-share-button share-fb"})
    return elem.attrs["data-url"]

login(username, password)

Log in to Rumble

Parameters:

Name Type Description Default
username str

Username to sign in with.

required
password str

Password to sign in with.

required

Returns:

Name Type Description
Cookie dict

Cookie dict to be passed with requests, which authenticates them.

Source code in cocorum/servicephp.py
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
def login(self, username: str, password: str):
    """Log in to Rumble

Args:
    username (str): Username to sign in with.
    password (str): Password to sign in with.

Returns:
    Cookie (dict): Cookie dict to be passed with requests, which authenticates them.
    """

    #Get salts
    r = self.sphp_request(
            "user.get_salts",
            data = {"username": username},
            logged_in = False,
            )
    salts = r.json()["data"]["salts"]

    #Get session token
    r = self.sphp_request(
            "user.login",
            data = {
                "username": username,

                #Hash the password using the salts
                "password_hashes": ",".join(utils.calc_password_hashes(password, salts)),
                },
            logged_in = False,
            )
    j = r.json()
    session_token = j["data"]["session"]
    assert session_token, f"Login failed: No token returned\n{r.json()}"

    return {static.Misc.session_token_key: session_token}

mute_user(username, is_channel=False, video=None, duration=None, total=False)

Mute a user or channel by name.

Parameters:

Name Type Description Default
username str

The user to mute.

required
is_channel bool

Is this a channel name rather than a username?

False
video int

The video to mute the user on. Defaults to None.

None
duration int

How long the user will be muted for, in seconds. Defaults to infinity.

None
total bool

Is this a mute across all videos? Defaults to False, requires video if False.

False
Source code in cocorum/servicephp.py
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
def mute_user(self, username: str, is_channel: bool = False, video: int = None, duration: int = None, total: bool = False):
    """Mute a user or channel by name.

Args:
    username (str): The user to mute.
    is_channel (bool): Is this a channel name rather than a username?
    video (int): The video to mute the user on.
        Defaults to None.
    duration (int): How long the user will be muted for, in seconds.
        Defaults to infinity.
    total (bool): Is this a mute across all videos?
        Defaults to False, requires video if False.
        """

    self.sphp_request(
        "moderation.mute",
        data = {
            "user_to_mute": username,
            "entity_type": ("user", "channel")[is_channel],
            "video": int(video),
            "duration": duration,
            "type": ("video", "total")[total],
            },
        )

playlist_add(title, description='', visibility='public', channel_id=None)

Create a new playlist.

Parameters:

Name Type Description Default
title str

The title of the playlist.

required
description str

Describe the playlist. Defaults to nothing.

''
visibility str

Set to public, unlisted, or private via string. Defaults to 'public'.

'public'
channel_id (int, str)

The ID of the channel to create the playlist under. Defaults to none.

None

Returns:

Name Type Description
Playlist APIPlaylist

The playlist as parsed from the response data.

Source code in cocorum/servicephp.py
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
def playlist_add(self, title: str, description: str = "", visibility: str = "public", channel_id = None):
    """Create a new playlist.

Args:
    title (str): The title of the playlist.
    description (str): Describe the playlist.
        Defaults to nothing.
    visibility (str): Set to public, unlisted, or private via string.
        Defaults to 'public'.
    channel_id (int, str): The ID of the channel to create the playlist under.
        Defaults to none.

Returns:
    Playlist (APIPlaylist): The playlist as parsed from the response data.
    """

    r = self.sphp_request(
        "playlist.add",
        data = {
            "title": str(title),
            "description": str(description),
            "visibility": str(visibility),
            "channel_id": str(utils.ensure_b10(channel_id)) if channel_id else None,
        }
    )
    return APIPlaylist(r.json()["data"])

playlist_add_video(playlist_id, video_id)

Add a video to a playlist.

Parameters:

Name Type Description Default
playlist_id str

The numeric ID of the playlist in base 36.

required
video_id (int, str)

The numeric ID of the video to add, in base 10 or 36.

required
Source code in cocorum/servicephp.py
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
def playlist_add_video(self, playlist_id: str, video_id):
    """Add a video to a playlist.

Args:
    playlist_id (str): The numeric ID of the playlist in base 36.
    video_id (int, str): The numeric ID of the video to add, in base 10 or 36.
    """

    print(self.sphp_request(
        "playlist.add_video",
        data = {
            "playlist_id": str(playlist_id),
            "video_id": utils.ensure_b10(video_id),
            }
        ).text)

playlist_delete(playlist_id)

Delete a playlist.

Parameters:

Name Type Description Default
playlist_id str

The numeric ID of the playlist to delete in base 36.

required
Source code in cocorum/servicephp.py
855
856
857
858
859
860
861
862
863
864
865
def playlist_delete(self, playlist_id: str):
    """Delete a playlist.

Args:
    playlist_id (str): The numeric ID of the playlist to delete in base 36.
    """

    print(self.sphp_request(
        "playlist.delete",
        data = {"playlist_id" : utils.ensure_b36(playlist_id)},
        ).text)

playlist_delete_video(playlist_id, video_id)

Remove a video from a playlist.

Parameters:

Name Type Description Default
playlist_id str

The numeric ID of the playlist in base 36.

required
video_id (int, str)

The numeric ID of the video to remove, in base 10 or 36.

required
Source code in cocorum/servicephp.py
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
def playlist_delete_video(self, playlist_id: str, video_id: int):
    """Remove a video from a playlist.

Args:
    playlist_id (str): The numeric ID of the playlist in base 36.
    video_id (int, str): The numeric ID of the video to remove, in base 10 or 36.
    """

    print(self.sphp_request(
        "playlist.delete_video",
        data = {
            "playlist_id": str(playlist_id),
            "video_id": utils.ensure_b10(video_id),
            }
        ).text)

playlist_edit(playlist_id, title, description='', visibility='public', channel_id=None)

Edit the details of an existing playlist

Parameters:

Name Type Description Default
playlist_id str

The numeric ID of the playlist to edit in base 36.

required
title str

The title of the playlist.

required
description str

Describe the playlist. Defaults to nothing.

''
visibility str

Set to public, unlisted, or private via string. Defaults to 'public'.

'public'
channel_id (int, str)

The ID of the channel to create the playlist under. Defaults to none.

None

Returns:

Name Type Description
Playlist APIPlaylist

The playlist as parsed from the response data.

Source code in cocorum/servicephp.py
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
def playlist_edit(self, playlist_id: str, title: str, description: str = "", visibility: str = "public", channel_id = None):
    """Edit the details of an existing playlist

Args:
    playlist_id (str): The numeric ID of the playlist to edit in base 36.
    title (str): The title of the playlist.
    description (str): Describe the playlist.
        Defaults to nothing.
    visibility (str): Set to public, unlisted, or private via string.
        Defaults to 'public'.
    channel_id (int, str): The ID of the channel to create the playlist under.
        Defaults to none.

Returns:
    Playlist (APIPlaylist): The playlist as parsed from the response data.
    """

    r = self.sphp_request(
        "playlist.edit",
        data = {
            "title": str(title),
            "description": str(description),
            "visibility": str(visibility),
            "channel_id": str(utils.ensure_b10(channel_id)) if channel_id else None,
            "playlist_id": utils.ensure_b36(playlist_id),
        }
    )
    return APIPlaylist(r.json()["data"])

raid_confirm(stream_id)

Confirm a raid, previously set up by command.

Parameters:

Name Type Description Default
stream_id (int, str)

The numeric ID of the stream to confirm the raid from, in base 10 or 36.

required
Source code in cocorum/servicephp.py
867
868
869
870
871
872
873
874
875
876
877
def raid_confirm(self, stream_id):
    """Confirm a raid, previously set up by command.

Args:
    stream_id (int, str): The numeric ID of the stream to confirm the raid from, in base 10 or 36.
    """

    self.sphp_request(
        "raid.confirm",
        data = {"video_id" : utils.ensure_b10(stream_id)},
        )

rumbles(vote, item_id, item_type)

Post a like or dislike.

Parameters:

Name Type Description Default
vote int

-1, 0, or 1 (0 means clear vote).

required
item_id int

The numeric ID of whatever we are liking or disliking.

required
item_type int

1 for video, 2 for comment.

required
Source code in cocorum/servicephp.py
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
def rumbles(self, vote: int, item_id, item_type: int):
    """Post a like or dislike.

Args:
    vote (int): -1, 0, or 1 (0 means clear vote).
    item_id (int): The numeric ID of whatever we are liking or disliking.
    item_type (int): 1 for video, 2 for comment.
    """

    r = self.sphp_request(
        "user.rumbles",
        data = {
            "type" : int(item_type),
            "id" : utils.ensure_b10(item_id),
            "vote" : int(vote),
            },
        )
    return APIContentVotes(r.json()["data"])

sphp_request(service_name, data={}, additional_params={}, logged_in=True, method='POST')

Make a request to Service.PHP with common settings service_name: The name parameter of the specific PHP service data: Form data additional_params: Any additional query string parameters logged_in: The request should use the session cookie

Source code in cocorum/servicephp.py
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
def sphp_request(self, service_name: str, data: dict = {}, additional_params: dict = {}, logged_in = True, method = "POST"):
    """Make a request to Service.PHP with common settings
    service_name: The name parameter of the specific PHP service
    data: Form data
    additional_params: Any additional query string parameters
    logged_in: The request should use the session cookie"""
    params = {"name" : service_name}
    params.update(additional_params)
    r = requests.request(
            method,
            static.URI.servicephp,
            params = params,
            data = data,
            headers = static.RequestHeaders.user_agent,
            cookies = self.session_cookie if logged_in else None,
            timeout = static.Delays.request_timeout,
            )
    assert r.status_code == 200, f"Service.PHP request for {service_name} failed: {r}\n{r.text}"
    #If the request json has a data -> success value, make sure it is True
    d = r.json().get("data")
    if isinstance(d, dict):
        assert d.get("success", True), f"Service.PHP request for {service_name} failed: \n{r.text}"
    #Data was not a dict but was not empty
    elif d:
        print(f"Service.PHP request for {service_name} did not fail but returned unknown data type {type(d)}: {d}")

    return r

unmute_user(record_id)

Unmute a user.

Parameters:

Name Type Description Default
record_id int

The numeric ID of the mute record to undo.

required
Source code in cocorum/servicephp.py
617
618
619
620
621
622
623
624
625
626
627
628
629
def unmute_user(self, record_id: int):
    """Unmute a user.

Args:
    record_id: The numeric ID of the mute record to undo.
    """

    self.sphp_request(
        "moderation.unmute",
        data = {
            "record_id" : record_id,
            }
        )

cocorum.uploadphp

UploadPHP

Interact with Rumble's Upload.PHP API to upload videos. S.D.G.

UploadPHP

Upload videos to Rumble

Source code in cocorum/uploadphp.py
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
class UploadPHP:
    """Upload videos to Rumble"""
    def __init__(self, servicephp):
        """Upload videos to Rumble.

    Args:
        servicephp (ServicePHP): ServicePHP object, for authentication.
        """

        self.servicephp = servicephp

        #Primary and secondary video categories AKA site and media channels
        self.categories1 = {}
        self.categories2 = {}
        self.get_categories()

        #Get list of channels we could use
        self.scraper = scraping.Scraper(self.servicephp)
        self.channels = self.scraper.get_channels()

        self.__cur_file_size = None
        self.__cur_upload_id = None
        self.__cur_num_chunks = None

    def get_categories(self):
        """Load the primary and secondary categories from Rumble"""
        #TODO: We may be able to get this from an internal API at studio.rumble.com instead
        # See issue #13

        print("Loading categories")
        r = self.uphp_request({}, method = "GET")
        soup = bs4.BeautifulSoup(r.text, features = "html.parser")

        options_box1 = soup.find("input", attrs = {"id" : "category_primary"}).parent
        options_elems1 = options_box1.find_all("div", attrs = {"class" : "select-option"})
        self.categories1 = {e.string.strip() : int(e.attrs["data-value"]) for e in options_elems1}

        options_box2 = soup.find("input", attrs = {"id" : "category_secondary"}).parent
        options_elems2 = options_box2.find_all("div", attrs = {"class" : "select-option"})
        self.categories2 = {e.string.strip() : int(e.attrs["data-value"]) for e in options_elems2}

        return self.categories1, self.categories2

    @property
    def session_cookie(self):
        """Our Rumble session cookie to authenticate requests"""
        return self.servicephp.session_cookie

    def uphp_request(self, additional_params: dict, method = "PUT", data: dict = None, timeout = static.Delays.request_timeout):
        """Make a request to Upload.PHP with common settings.

    Args:
        additional_params (dict): Query string parameters to add to the base ones
        method (str): What HTTP method to use for the request.
            Defaults to PUT.
        data (dict): Form data for the request.
            Defaults to None.
        timeout (int, float): Request timeout.
            Defaults to static.Delays.request_timeout

    Returns:
        Response (requests.models.Response): The response from the request.
        """

        params = {"api": static.Upload.api_ver}
        params.update(additional_params)
        r = requests.request(
                method,
                static.URI.uploadphp,
                params = params,
                data = data,
                headers = static.RequestHeaders.user_agent,
                cookies = self.session_cookie,
                timeout = timeout,
                )
        assert r.status_code == 200, f"Upload.PHP request failed: {r}\n{r.text}"
        #If the request json has a data -> success value, make sure it is True

        return r

    def ensure_valid_channel_id(self, channel_id):
        """Ensure a channel ID is numeric and a valid channel, or None

    Args:
        channel_id (int, None): The numeric ID of the channel.

    Returns:
        Result (int, None): Either the confirmed channel ID, or None if it didn't exist / wasn't specified.
        """

        #No channel selected
        if not channel_id:
            return None

        #Look for a channel match
        for c in self.channels:
            if c == channel_id:
                return c.channel_id_b10

        print(f"ERROR: No channel match for {channel_id}, defaulting to None")
        return None

    def chunked_vidfile_upload(self, file_path):
        """Upload a video file to Rumble in chunks

    Args:
        file_path (str): A valid, complete path to the video file for upload.

    Returns:
        Filename (str): The filename of the merged video on the server after upload.
        """

        print("Uploading video in", self.__cur_num_chunks, "chunks")

        #Base upload params
        upload_params = {
            "chunkSz": static.Upload.chunksz,
            "chunkQty": self.__cur_num_chunks,
            }

        with open(file_path, "rb") as f:
            for i in range(self.__cur_num_chunks):
                print(f"Uploading chunk {i + 1}/{self.__cur_num_chunks}")
                #Parameters for this chunk upload
                chunk_params = upload_params.copy()
                chunk_params.update({
                    "chunk": f"{i}_{self.__cur_upload_id}.mp4",
                    })

                #Get permission to upload the chunk
                assert utils.options_check(
                    static.URI.uploadphp,
                    "PUT",
                    cookies = self.session_cookie,
                    params = chunk_params,
                    ), f"Chunk {i} upload failed at OPTIONS request."
                #Upload the chunk
                self.uphp_request(chunk_params, data = f.read(static.Upload.chunksz), timeout = 300) #Set static? TODO

        #Params for the merge request
        merge_params = upload_params.copy()
        merge_params.update({
            "merge": i,
            "chunk": f"{self.__cur_upload_id}.mp4",
            })

        #Tell the server to merge the chunks
        print("Merging chunks at server")
        r = self.uphp_request(merge_params)
        merged_video_fn = r.text
        print("Merged to", merged_video_fn)
        return merged_video_fn

    def unchunked_vidfile_upload(self, file_path):
        """Upload a video file to Rumble all at once

    Args:
        file_path (str): A valid, complete path to the video file for upload.

    Returns:
        Filename (str): The filename of the video on the server after upload.
        """

        print("Uploading video")

        with open(file_path, "rb") as f:
            #Get permission to upload the file
            assert utils.options_check(
                static.URI.uploadphp,
                "PUT",
                cookies = self.session_cookie,
                params = {"api": static.Upload.api_ver},
                ), "File upload failed at OPTIONS request."
            #Upload the file
            r = self.uphp_request({}, data = f.read(), timeout = 300) #Set static? TODO

        uploaded_fn = r.text
        print("Video file on server is", uploaded_fn)
        return uploaded_fn

    def upload_cthumb(self, file_path):
        """Upload a custom thumbnail for a video

    Args:
        file_path (str): A valid, complete path to the image file for upload.

    Returns:
        Filename (str): Filename of the image on the server after upload.
        """

        print("Uploading custom thumbnail")
        ext = file_path.split(".")[-1]
        ct_server_filename = "ct-" + self.__cur_upload_id + "." + ext
        with open(file_path, "rb") as f:
            assert self.uphp_request(
                {"cthumb" : ct_server_filename},
                data = {"customThumb" : f.read()},
                ).text.strip() == ct_server_filename, "Unexpected thumbnail upload response"

        print("Thumbnail file on server is", ct_server_filename)
        return ct_server_filename

    def upload_video(self, file_path, title: str, category1, **kwargs):
        """Upload a video to Rumble

    Args:
        file_path (str): A valid, complete path to a video file.
        title (str): The video title.
        category1 (int, str): The primary category to upload to, by name or numeric ID.
        description (str): Describe the video.
            Defaults to empty.
        info_who (str): Additional people appearing in the video.
            Defaults to empty.
        info_when (str): When this video was recorded.
            Defaults to unspecified.
        info_where (str): Where this video was recorded.
            Defaults to unspecified.
        info_ext_user (str): Your username on other platforms.
            Defaults to unspecified.
        tags (str): Comma-separated tagging for the video's topics.
            Defaults to empty.
        category2 (int, str): The secondary category to upload to, by name or numeric ID.
            Defaults to empty
        channel_id (int, str): Numeric ID or name of the channel to upload to.
            Defaults to user page upload.
        visibility (str): Visibility of the video, either public, unlisted, or private.
            Defaults to 'public'.
        availability: TODO
            Defaults to free.
        scheduled_publish (int, float): When to publish the video to public later, in seconds since epoch.
            Defaults to publish immediately.
        thumbnail (int, str): Thumbnail to use. Index 0-2 for an auto thumbnail, or a complete, valid local file path for custom.
            Defaults to 0, first auto thumbnail.

    Returns:
        Response (UploadResponse): Data about the upload, parsed from the response.
        """

        assert os.path.exists(file_path), "Video file does not exist on disk"

        self.__cur_file_size = os.path.getsize(file_path)

        assert self.__cur_file_size < static.Upload.max_filesize, "File is too big"

        start_time = int(time.time() * 1000)

        #IDK if the second half of this is correct, TODO
        self.__cur_upload_id = f"{start_time}-{random.randrange(1000000) :06}"

        #Is the file large enough that it needs to be chunked
        if self.__cur_file_size > static.Upload.chunksz:
            #Number of chunks we will need to do, rounded up
            self.__cur_num_chunks = self.__cur_file_size // static.Upload.chunksz + 1
            server_filename = self.chunked_vidfile_upload(file_path)
        else:
            server_filename = self.unchunked_vidfile_upload(file_path)

        end_time = int(time.time() * 1000)

        #Get the uploaded duration
        r = self.uphp_request({"duration": server_filename})
        checked_duration = float(r.text)
        print("Server says video duration is", checked_duration)

        #Get thumbnails
        auto_thumbnails = self.uphp_request({"thumbnails" : server_filename}).json()

        thumbnail = kwargs.get("thumbnail", 0)

        #thumbnail is an auto index
        if isinstance(thumbnail, int):
            assert 0 <= thumbnail <= len(auto_thumbnails), "Thumbnail index is invalid"
            thumbnail = list(auto_thumbnails.keys())[thumbnail]

        #Thumbnail is path string
        elif isinstance(thumbnail, str):
            assert os.path.exists(thumbnail), "Thumbnail was a str but is not a valid path"
            thumbnail = self.upload_cthumb(thumbnail)

        #Unknown
        else:
            raise ValueError("Thumbnail argument is of unknown type")

        #Get the primary category
        if isinstance(category1, str):
            category1 = category1.strip()
            if category1.isnumeric():
                category1 = int(category1)
            else:
                category1 = self.categories1[category1]
        assert isinstance(category1, (int, float)), f"Primary category must be number or str name, got {type(category1)}"

        #Get the secondary category, but allow None
        category2 = kwargs.get("category2")
        if isinstance(category2, str):
            category2 = category2.strip()
            if category2.isnumeric():
                category2 = int(category1)
            else:
                category2 = self.categories2[category2]
        assert isinstance(category2, (int, float)) or category2 is None, f"Secondary category must be number or str name, got {type(category1)}"

        #Publish the upload
        updata = {
            "title": title,
            "description": kwargs.get("description", ""),
            "video[]": server_filename,
            "featured": "6", #Never seems to change
            "rights": "1",
            "terms": "1",
            "facebookUpload": "",
            "vimeoUpload": "",
            "infoWho": kwargs.get("info_who", ""),
            "infoWhen": kwargs.get("info_when", ""),
            "infoWhere": kwargs.get("info_where", ""),
            "infoExtUser": kwargs.get("info_ext_user", ""),
            "tags": kwargs.get("tags", ""),
            "channelId": str(self.ensure_valid_channel_id(kwargs.get("channel_id", 0))),
            "siteChannelId": str(category1),
            "mediaChannelId": str(category2),
            "isGamblingRelated": "false",
            "set_default_channel_id": "0", #Set to 1 to "Set this channel as default" on Rumble
            #Scheduled visibility takes precedent over visibility setting
            "visibility": kwargs.get("visibility", "public") if not kwargs.get("scheduled_publish") else "private",
            "availability": kwargs.get("availability", "free"),
            "file_meta": {
                "name": os.path.basename(file_path), #File name
                "modified": int(os.path.getmtime(file_path) * 1000), #Timestamp file was modified, miliseconds
                "size": self.__cur_file_size, #Exact length of entire MP4 file in bytes
                "type": mimetypes.guess_file_type(file_path)[0],
                "time_start": start_time, #Timestamp file started uploading, miliseconds
                "speed": int(self.__cur_file_size / (end_time - start_time) * 1000),
                "num_chunks": self.__cur_num_chunks,
                "time_end": end_time, #Timestamp we finished uploading, miliseconds
                },
            "schedulerDatetime": utils.form_timestamp(kwargs.get("scheduled_publish")) if kwargs.get("scheduled_publish") else "",
            "thumb": str(thumbnail),
            }

        print("Publishing uploaded video")
        r = self.uphp_request(
            {"form" : "1"},
            data = updata,
            method = "POST",
            )

        #Extract the json from the response HTML, and return it as an JSONObj derivative
        return UploadResponse(json.loads(r.text[r.text.find("{") : r.text.rfind("}") + 1]))

Our Rumble session cookie to authenticate requests

__init__(servicephp)

Upload videos to Rumble.

Parameters:

Name Type Description Default
servicephp ServicePHP

ServicePHP object, for authentication.

required
Source code in cocorum/uploadphp.py
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def __init__(self, servicephp):
    """Upload videos to Rumble.

Args:
    servicephp (ServicePHP): ServicePHP object, for authentication.
    """

    self.servicephp = servicephp

    #Primary and secondary video categories AKA site and media channels
    self.categories1 = {}
    self.categories2 = {}
    self.get_categories()

    #Get list of channels we could use
    self.scraper = scraping.Scraper(self.servicephp)
    self.channels = self.scraper.get_channels()

    self.__cur_file_size = None
    self.__cur_upload_id = None
    self.__cur_num_chunks = None

chunked_vidfile_upload(file_path)

Upload a video file to Rumble in chunks

Parameters:

Name Type Description Default
file_path str

A valid, complete path to the video file for upload.

required

Returns:

Name Type Description
Filename str

The filename of the merged video on the server after upload.

Source code in cocorum/uploadphp.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
def chunked_vidfile_upload(self, file_path):
    """Upload a video file to Rumble in chunks

Args:
    file_path (str): A valid, complete path to the video file for upload.

Returns:
    Filename (str): The filename of the merged video on the server after upload.
    """

    print("Uploading video in", self.__cur_num_chunks, "chunks")

    #Base upload params
    upload_params = {
        "chunkSz": static.Upload.chunksz,
        "chunkQty": self.__cur_num_chunks,
        }

    with open(file_path, "rb") as f:
        for i in range(self.__cur_num_chunks):
            print(f"Uploading chunk {i + 1}/{self.__cur_num_chunks}")
            #Parameters for this chunk upload
            chunk_params = upload_params.copy()
            chunk_params.update({
                "chunk": f"{i}_{self.__cur_upload_id}.mp4",
                })

            #Get permission to upload the chunk
            assert utils.options_check(
                static.URI.uploadphp,
                "PUT",
                cookies = self.session_cookie,
                params = chunk_params,
                ), f"Chunk {i} upload failed at OPTIONS request."
            #Upload the chunk
            self.uphp_request(chunk_params, data = f.read(static.Upload.chunksz), timeout = 300) #Set static? TODO

    #Params for the merge request
    merge_params = upload_params.copy()
    merge_params.update({
        "merge": i,
        "chunk": f"{self.__cur_upload_id}.mp4",
        })

    #Tell the server to merge the chunks
    print("Merging chunks at server")
    r = self.uphp_request(merge_params)
    merged_video_fn = r.text
    print("Merged to", merged_video_fn)
    return merged_video_fn

ensure_valid_channel_id(channel_id)

Ensure a channel ID is numeric and a valid channel, or None

Parameters:

Name Type Description Default
channel_id (int, None)

The numeric ID of the channel.

required

Returns:

Name Type Description
Result (int, None)

Either the confirmed channel ID, or None if it didn't exist / wasn't specified.

Source code in cocorum/uploadphp.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def ensure_valid_channel_id(self, channel_id):
    """Ensure a channel ID is numeric and a valid channel, or None

Args:
    channel_id (int, None): The numeric ID of the channel.

Returns:
    Result (int, None): Either the confirmed channel ID, or None if it didn't exist / wasn't specified.
    """

    #No channel selected
    if not channel_id:
        return None

    #Look for a channel match
    for c in self.channels:
        if c == channel_id:
            return c.channel_id_b10

    print(f"ERROR: No channel match for {channel_id}, defaulting to None")
    return None

get_categories()

Load the primary and secondary categories from Rumble

Source code in cocorum/uploadphp.py
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def get_categories(self):
    """Load the primary and secondary categories from Rumble"""
    #TODO: We may be able to get this from an internal API at studio.rumble.com instead
    # See issue #13

    print("Loading categories")
    r = self.uphp_request({}, method = "GET")
    soup = bs4.BeautifulSoup(r.text, features = "html.parser")

    options_box1 = soup.find("input", attrs = {"id" : "category_primary"}).parent
    options_elems1 = options_box1.find_all("div", attrs = {"class" : "select-option"})
    self.categories1 = {e.string.strip() : int(e.attrs["data-value"]) for e in options_elems1}

    options_box2 = soup.find("input", attrs = {"id" : "category_secondary"}).parent
    options_elems2 = options_box2.find_all("div", attrs = {"class" : "select-option"})
    self.categories2 = {e.string.strip() : int(e.attrs["data-value"]) for e in options_elems2}

    return self.categories1, self.categories2

unchunked_vidfile_upload(file_path)

Upload a video file to Rumble all at once

Parameters:

Name Type Description Default
file_path str

A valid, complete path to the video file for upload.

required

Returns:

Name Type Description
Filename str

The filename of the video on the server after upload.

Source code in cocorum/uploadphp.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
def unchunked_vidfile_upload(self, file_path):
    """Upload a video file to Rumble all at once

Args:
    file_path (str): A valid, complete path to the video file for upload.

Returns:
    Filename (str): The filename of the video on the server after upload.
    """

    print("Uploading video")

    with open(file_path, "rb") as f:
        #Get permission to upload the file
        assert utils.options_check(
            static.URI.uploadphp,
            "PUT",
            cookies = self.session_cookie,
            params = {"api": static.Upload.api_ver},
            ), "File upload failed at OPTIONS request."
        #Upload the file
        r = self.uphp_request({}, data = f.read(), timeout = 300) #Set static? TODO

    uploaded_fn = r.text
    print("Video file on server is", uploaded_fn)
    return uploaded_fn

uphp_request(additional_params, method='PUT', data=None, timeout=static.Delays.request_timeout)

Make a request to Upload.PHP with common settings.

Parameters:

Name Type Description Default
additional_params dict

Query string parameters to add to the base ones

required
method str

What HTTP method to use for the request. Defaults to PUT.

'PUT'
data dict

Form data for the request. Defaults to None.

None
timeout (int, float)

Request timeout. Defaults to static.Delays.request_timeout

request_timeout

Returns:

Name Type Description
Response Response

The response from the request.

Source code in cocorum/uploadphp.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def uphp_request(self, additional_params: dict, method = "PUT", data: dict = None, timeout = static.Delays.request_timeout):
    """Make a request to Upload.PHP with common settings.

Args:
    additional_params (dict): Query string parameters to add to the base ones
    method (str): What HTTP method to use for the request.
        Defaults to PUT.
    data (dict): Form data for the request.
        Defaults to None.
    timeout (int, float): Request timeout.
        Defaults to static.Delays.request_timeout

Returns:
    Response (requests.models.Response): The response from the request.
    """

    params = {"api": static.Upload.api_ver}
    params.update(additional_params)
    r = requests.request(
            method,
            static.URI.uploadphp,
            params = params,
            data = data,
            headers = static.RequestHeaders.user_agent,
            cookies = self.session_cookie,
            timeout = timeout,
            )
    assert r.status_code == 200, f"Upload.PHP request failed: {r}\n{r.text}"
    #If the request json has a data -> success value, make sure it is True

    return r

upload_cthumb(file_path)

Upload a custom thumbnail for a video

Parameters:

Name Type Description Default
file_path str

A valid, complete path to the image file for upload.

required

Returns:

Name Type Description
Filename str

Filename of the image on the server after upload.

Source code in cocorum/uploadphp.py
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
def upload_cthumb(self, file_path):
    """Upload a custom thumbnail for a video

Args:
    file_path (str): A valid, complete path to the image file for upload.

Returns:
    Filename (str): Filename of the image on the server after upload.
    """

    print("Uploading custom thumbnail")
    ext = file_path.split(".")[-1]
    ct_server_filename = "ct-" + self.__cur_upload_id + "." + ext
    with open(file_path, "rb") as f:
        assert self.uphp_request(
            {"cthumb" : ct_server_filename},
            data = {"customThumb" : f.read()},
            ).text.strip() == ct_server_filename, "Unexpected thumbnail upload response"

    print("Thumbnail file on server is", ct_server_filename)
    return ct_server_filename

upload_video(file_path, title, category1, **kwargs)

Upload a video to Rumble

Parameters:

Name Type Description Default
file_path str

A valid, complete path to a video file.

required
title str

The video title.

required
category1 (int, str)

The primary category to upload to, by name or numeric ID.

required
description str

Describe the video. Defaults to empty.

required
info_who str

Additional people appearing in the video. Defaults to empty.

required
info_when str

When this video was recorded. Defaults to unspecified.

required
info_where str

Where this video was recorded. Defaults to unspecified.

required
info_ext_user str

Your username on other platforms. Defaults to unspecified.

required
tags str

Comma-separated tagging for the video's topics. Defaults to empty.

required
category2 (int, str)

The secondary category to upload to, by name or numeric ID. Defaults to empty

required
channel_id (int, str)

Numeric ID or name of the channel to upload to. Defaults to user page upload.

required
visibility str

Visibility of the video, either public, unlisted, or private. Defaults to 'public'.

required
availability

TODO Defaults to free.

required
scheduled_publish (int, float)

When to publish the video to public later, in seconds since epoch. Defaults to publish immediately.

required
thumbnail (int, str)

Thumbnail to use. Index 0-2 for an auto thumbnail, or a complete, valid local file path for custom. Defaults to 0, first auto thumbnail.

required

Returns:

Name Type Description
Response UploadResponse

Data about the upload, parsed from the response.

Source code in cocorum/uploadphp.py
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
def upload_video(self, file_path, title: str, category1, **kwargs):
    """Upload a video to Rumble

Args:
    file_path (str): A valid, complete path to a video file.
    title (str): The video title.
    category1 (int, str): The primary category to upload to, by name or numeric ID.
    description (str): Describe the video.
        Defaults to empty.
    info_who (str): Additional people appearing in the video.
        Defaults to empty.
    info_when (str): When this video was recorded.
        Defaults to unspecified.
    info_where (str): Where this video was recorded.
        Defaults to unspecified.
    info_ext_user (str): Your username on other platforms.
        Defaults to unspecified.
    tags (str): Comma-separated tagging for the video's topics.
        Defaults to empty.
    category2 (int, str): The secondary category to upload to, by name or numeric ID.
        Defaults to empty
    channel_id (int, str): Numeric ID or name of the channel to upload to.
        Defaults to user page upload.
    visibility (str): Visibility of the video, either public, unlisted, or private.
        Defaults to 'public'.
    availability: TODO
        Defaults to free.
    scheduled_publish (int, float): When to publish the video to public later, in seconds since epoch.
        Defaults to publish immediately.
    thumbnail (int, str): Thumbnail to use. Index 0-2 for an auto thumbnail, or a complete, valid local file path for custom.
        Defaults to 0, first auto thumbnail.

Returns:
    Response (UploadResponse): Data about the upload, parsed from the response.
    """

    assert os.path.exists(file_path), "Video file does not exist on disk"

    self.__cur_file_size = os.path.getsize(file_path)

    assert self.__cur_file_size < static.Upload.max_filesize, "File is too big"

    start_time = int(time.time() * 1000)

    #IDK if the second half of this is correct, TODO
    self.__cur_upload_id = f"{start_time}-{random.randrange(1000000) :06}"

    #Is the file large enough that it needs to be chunked
    if self.__cur_file_size > static.Upload.chunksz:
        #Number of chunks we will need to do, rounded up
        self.__cur_num_chunks = self.__cur_file_size // static.Upload.chunksz + 1
        server_filename = self.chunked_vidfile_upload(file_path)
    else:
        server_filename = self.unchunked_vidfile_upload(file_path)

    end_time = int(time.time() * 1000)

    #Get the uploaded duration
    r = self.uphp_request({"duration": server_filename})
    checked_duration = float(r.text)
    print("Server says video duration is", checked_duration)

    #Get thumbnails
    auto_thumbnails = self.uphp_request({"thumbnails" : server_filename}).json()

    thumbnail = kwargs.get("thumbnail", 0)

    #thumbnail is an auto index
    if isinstance(thumbnail, int):
        assert 0 <= thumbnail <= len(auto_thumbnails), "Thumbnail index is invalid"
        thumbnail = list(auto_thumbnails.keys())[thumbnail]

    #Thumbnail is path string
    elif isinstance(thumbnail, str):
        assert os.path.exists(thumbnail), "Thumbnail was a str but is not a valid path"
        thumbnail = self.upload_cthumb(thumbnail)

    #Unknown
    else:
        raise ValueError("Thumbnail argument is of unknown type")

    #Get the primary category
    if isinstance(category1, str):
        category1 = category1.strip()
        if category1.isnumeric():
            category1 = int(category1)
        else:
            category1 = self.categories1[category1]
    assert isinstance(category1, (int, float)), f"Primary category must be number or str name, got {type(category1)}"

    #Get the secondary category, but allow None
    category2 = kwargs.get("category2")
    if isinstance(category2, str):
        category2 = category2.strip()
        if category2.isnumeric():
            category2 = int(category1)
        else:
            category2 = self.categories2[category2]
    assert isinstance(category2, (int, float)) or category2 is None, f"Secondary category must be number or str name, got {type(category1)}"

    #Publish the upload
    updata = {
        "title": title,
        "description": kwargs.get("description", ""),
        "video[]": server_filename,
        "featured": "6", #Never seems to change
        "rights": "1",
        "terms": "1",
        "facebookUpload": "",
        "vimeoUpload": "",
        "infoWho": kwargs.get("info_who", ""),
        "infoWhen": kwargs.get("info_when", ""),
        "infoWhere": kwargs.get("info_where", ""),
        "infoExtUser": kwargs.get("info_ext_user", ""),
        "tags": kwargs.get("tags", ""),
        "channelId": str(self.ensure_valid_channel_id(kwargs.get("channel_id", 0))),
        "siteChannelId": str(category1),
        "mediaChannelId": str(category2),
        "isGamblingRelated": "false",
        "set_default_channel_id": "0", #Set to 1 to "Set this channel as default" on Rumble
        #Scheduled visibility takes precedent over visibility setting
        "visibility": kwargs.get("visibility", "public") if not kwargs.get("scheduled_publish") else "private",
        "availability": kwargs.get("availability", "free"),
        "file_meta": {
            "name": os.path.basename(file_path), #File name
            "modified": int(os.path.getmtime(file_path) * 1000), #Timestamp file was modified, miliseconds
            "size": self.__cur_file_size, #Exact length of entire MP4 file in bytes
            "type": mimetypes.guess_file_type(file_path)[0],
            "time_start": start_time, #Timestamp file started uploading, miliseconds
            "speed": int(self.__cur_file_size / (end_time - start_time) * 1000),
            "num_chunks": self.__cur_num_chunks,
            "time_end": end_time, #Timestamp we finished uploading, miliseconds
            },
        "schedulerDatetime": utils.form_timestamp(kwargs.get("scheduled_publish")) if kwargs.get("scheduled_publish") else "",
        "thumb": str(thumbnail),
        }

    print("Publishing uploaded video")
    r = self.uphp_request(
        {"form" : "1"},
        data = updata,
        method = "POST",
        )

    #Extract the json from the response HTML, and return it as an JSONObj derivative
    return UploadResponse(json.loads(r.text[r.text.find("{") : r.text.rfind("}") + 1]))

UploadResponse

Bases: JSONObj

Response to a successful video upload

Source code in cocorum/uploadphp.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class UploadResponse(JSONObj):
    """Response to a successful video upload"""
    @property
    def url(self):
        """The video viewing URL"""
        return self["url"]

    @property
    def fid(self):
        """The numeric ID of the uploaded video in base 10"""
        return int(self["fid"])

    @property
    def fid_b10(self):
        """The numeric ID of the uploaded video in base 10"""
        return self.fid

    @property
    def fid_b36(self):
        """The numeric ID of the uploaded video in base 36"""
        return utils.base_10_to_36(self.fid)

    @property
    def title(self):
        """The title of the video"""
        return self["title"]

    @property
    def embed(self):
        """HTML to use for embedding the video"""
        return self["embed"]

    @property
    def embed_monetize(self):
        """HTML to use for embedding the video with monetization"""
        return self["embedMonetize"]

embed property

HTML to use for embedding the video

embed_monetize property

HTML to use for embedding the video with monetization

fid property

The numeric ID of the uploaded video in base 10

fid_b10 property

The numeric ID of the uploaded video in base 10

fid_b36 property

The numeric ID of the uploaded video in base 36

title property

The title of the video

url property

The video viewing URL

cocorum.scraping

Scraping for Cocorum

Classes and utilities for extracting data from HTML, including that returned by the API. S.D.G.

HTMLChannel

Bases: HTMLObj

Channel under a user as extracted from their channels page

Source code in cocorum/scraping.py
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
class HTMLChannel(HTMLObj):
    """Channel under a user as extracted from their channels page"""

    def __str__(self):
        """The channel as a string (its slug)"""
        return self.slug

    def __int__(self):
        """The channel as an integer (its numeric ID)"""
        return self.channel_id_b10

    def __eq__(self, other):
        """Determine if this channel is equal to another.

    Args:
        other (int, str, HTMLChannel): Object to compare to.

    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """

        #Check for direct matches first
        if isinstance(other, int):
            return self.channel_id_b10 == other
        if isinstance(other, str):
            return str(other) in (self.slug, self.channel_id_b36)

        #Check for object attributes to match to
        if hasattr(other, "channel_id"):
            return self.channel_id_b10 == utils.ensure_b10(other.channel_id)
        if hasattr(other, "slug"):
            return self.slug == other.slug

        #Check conversion to integer last, in case an ID or something happens to match but the other is not actually a channel
        if hasattr(other, "__int__"):
            return self.channel_id_b10 == int(other)

    @property
    def slug(self):
        """The unique string ID of the channel"""
        return self["data-slug"]

    @property
    def channel_id(self):
        """The numeric ID of the channel in base 10"""
        return int(self["data-id"])

    @property
    def channel_id_b10(self):
        """The numeric ID of the channel in base 10"""
        return self.channel_id

    @property
    def channel_id_b36(self):
        """The numeric ID of the channel in base 36"""
        return utils.base_10_to_36(self.channel_id)

    @property
    def title(self):
        """The title of the channel"""
        return self["data-title"]

channel_id property

The numeric ID of the channel in base 10

channel_id_b10 property

The numeric ID of the channel in base 10

channel_id_b36 property

The numeric ID of the channel in base 36

slug property

The unique string ID of the channel

title property

The title of the channel

__eq__(other)

Determine if this channel is equal to another.

Parameters:

Name Type Description Default
other (int, str, HTMLChannel)

Object to compare to.

required

Returns:

Name Type Description
Comparison (bool, None)

Did it fit the criteria?

Source code in cocorum/scraping.py
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
def __eq__(self, other):
    """Determine if this channel is equal to another.

Args:
    other (int, str, HTMLChannel): Object to compare to.

Returns:
    Comparison (bool, None): Did it fit the criteria?
    """

    #Check for direct matches first
    if isinstance(other, int):
        return self.channel_id_b10 == other
    if isinstance(other, str):
        return str(other) in (self.slug, self.channel_id_b36)

    #Check for object attributes to match to
    if hasattr(other, "channel_id"):
        return self.channel_id_b10 == utils.ensure_b10(other.channel_id)
    if hasattr(other, "slug"):
        return self.slug == other.slug

    #Check conversion to integer last, in case an ID or something happens to match but the other is not actually a channel
    if hasattr(other, "__int__"):
        return self.channel_id_b10 == int(other)

__int__()

The channel as an integer (its numeric ID)

Source code in cocorum/scraping.py
394
395
396
def __int__(self):
    """The channel as an integer (its numeric ID)"""
    return self.channel_id_b10

__str__()

The channel as a string (its slug)

Source code in cocorum/scraping.py
390
391
392
def __str__(self):
    """The channel as a string (its slug)"""
    return self.slug

HTMLComment

Bases: HTMLObj

A comment on a video as returned by service.php comment.list

Source code in cocorum/scraping.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
class HTMLComment(HTMLObj):
    """A comment on a video as returned by service.php comment.list"""
    def __init__(self, elem):
        """A comment on a video as returned by service.php comment.list

    Args:
        elem (bs4.Tag): The <li> element of the comment.
        """

        super().__init__(elem)

        #Badges of the user who commented if we have them
        badges_unkeyed = (HTMLUserBadge(badge_elem) for badge_elem in self._elem.find_all("li", attrs = {"class" : "comments-meta-user-badge"}))
        self.user_badges = {badge.slug : badge for badge in badges_unkeyed}

    def __int__(self):
        """The comment in integer form (its ID)"""
        return self.comment_id

    def __str__(self):
        """The comment as a string (its text)"""
        return self.text

    def __eq__(self, other):
        """Determine if this comment is equal to another.

    Args:
        other (int, str, HTMLComment): Object to compare to.

    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """

        #Check for direct matches first
        if isinstance(other, int):
            return self.comment_id_b10 == other
        if isinstance(other, str):
            return str(self) == other

        #Check for object attributes to match to
        if hasattr(other, "comment_id"):
            return self.comment_id_b10 == utils.ensure_b10(other.comment_id)

        #Check conversion to integer last
        if hasattr(other, "__int__"):
            return self.comment_id_b10 == int(other)

    @property
    def is_first(self):
        """Is this comment the first one?"""
        return "comment-item-first" in self["class"]

    @property
    def comment_id(self):
        """The numeric ID of the comment in base 10"""
        return int(self["data-comment-id"])

    @property
    def comment_id_b10(self):
        """The base 10 ID of the comment"""
        return self.comment_id

    @property
    def comment_id_b36(self):
        """The base 36 ID of the comment"""
        return utils.base_10_to_36(self.comment_id)

    @property
    def text(self):
        """The text of the comment"""
        return self._elem.find("p", attrs = {"class" : "comment-text"}).string

    @property
    def username(self):
        """The name of the user who commented"""
        return self["data-username"]

    @property
    def entity_type(self):
        """Wether the comment was made by a user or a channel"""
        return self["data-entity-type"]

    @property
    def video_id(self):
        """The base 10 ID of the video the comment was posted on"""
        return self["data-video-fid"]

    @property
    def video_id_b10(self):
        """The base 10 ID of the video the comment was posted on"""
        return self.video_id

    @property
    def video_id_b36(self):
        """The base 36 ID of the video the comment was posted on"""
        return utils.base_10_to_36(self.video_id)

    @property
    def actions(self):
        """Allowed actions on this comment based on the login used to retrieve it"""
        return self["data-actions"].split(",")

    @property
    def rumbles(self):
        """The votes on this comment"""
        return HTMLContentVotes(self._elem.find("div", attrs = {"class" : "rumbles-vote"}))

actions property

Allowed actions on this comment based on the login used to retrieve it

comment_id property

The numeric ID of the comment in base 10

comment_id_b10 property

The base 10 ID of the comment

comment_id_b36 property

The base 36 ID of the comment

entity_type property

Wether the comment was made by a user or a channel

is_first property

Is this comment the first one?

rumbles property

The votes on this comment

text property

The text of the comment

username property

The name of the user who commented

video_id property

The base 10 ID of the video the comment was posted on

video_id_b10 property

The base 10 ID of the video the comment was posted on

video_id_b36 property

The base 36 ID of the video the comment was posted on

__eq__(other)

Determine if this comment is equal to another.

Parameters:

Name Type Description Default
other (int, str, HTMLComment)

Object to compare to.

required

Returns:

Name Type Description
Comparison (bool, None)

Did it fit the criteria?

Source code in cocorum/scraping.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def __eq__(self, other):
    """Determine if this comment is equal to another.

Args:
    other (int, str, HTMLComment): Object to compare to.

Returns:
    Comparison (bool, None): Did it fit the criteria?
    """

    #Check for direct matches first
    if isinstance(other, int):
        return self.comment_id_b10 == other
    if isinstance(other, str):
        return str(self) == other

    #Check for object attributes to match to
    if hasattr(other, "comment_id"):
        return self.comment_id_b10 == utils.ensure_b10(other.comment_id)

    #Check conversion to integer last
    if hasattr(other, "__int__"):
        return self.comment_id_b10 == int(other)

__init__(elem)

A comment on a video as returned by service.php comment.list

Parameters:

Name Type Description Default
elem Tag

The

  • element of the comment.

  • required
    Source code in cocorum/scraping.py
     91
     92
     93
     94
     95
     96
     97
     98
     99
    100
    101
    102
    def __init__(self, elem):
        """A comment on a video as returned by service.php comment.list
    
    Args:
        elem (bs4.Tag): The <li> element of the comment.
        """
    
        super().__init__(elem)
    
        #Badges of the user who commented if we have them
        badges_unkeyed = (HTMLUserBadge(badge_elem) for badge_elem in self._elem.find_all("li", attrs = {"class" : "comments-meta-user-badge"}))
        self.user_badges = {badge.slug : badge for badge in badges_unkeyed}
    

    __int__()

    The comment in integer form (its ID)

    Source code in cocorum/scraping.py
    104
    105
    106
    def __int__(self):
        """The comment in integer form (its ID)"""
        return self.comment_id
    

    __str__()

    The comment as a string (its text)

    Source code in cocorum/scraping.py
    108
    109
    110
    def __str__(self):
        """The comment as a string (its text)"""
        return self.text
    

    HTMLContentVotes

    Bases: HTMLObj

    Votes made on content

    Source code in cocorum/scraping.py
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    class HTMLContentVotes(HTMLObj):
        """Votes made on content"""
    
        def __int__(self):
            """The integer form of the content votes"""
            return self.score
    
        def __str__(self):
            """The string form of the content votes"""
            #return self.score_formatted
            return str(self.score)
    
        def __eq__(self, other):
            """Determine if this content votes is equal to another.
    
        Args:
            other (int, str, HTMLContentVotes): Object to compare to.
    
        Returns:
            Comparison (bool, None): Did it fit the criteria?
            """
    
            #Check for direct matches first
            if isinstance(other, int):
                return self.score == other
            if isinstance(other, str):
                return str(self) == other
    
            #Check for object attributes to match to
            if hasattr(other, "score"):
                #if hasattr(other, "content_id") and hasattr(other, "content_type"):
                #    return self.score, self.content_id, self.content_type == other.score, other.content_id, other.content_type
                return self.score == other.score
    
            #Check conversion to integer last
            if hasattr(other, "__int__"):
                return self.score == int(other)
    
        @property
        def score(self):
            """Summed score of the content"""
            return int(self._elem.find("span", attrs = {"class" : "rumbles-count"}).string)
    
        @property
        def content_type(self):
            """The type of content being voted on"""
            return int(self["data-type"])
    
        @property
        def content_id(self):
            """The numerical ID of the content being voted on"""
            return int(self["data-id"])
    

    content_id property

    The numerical ID of the content being voted on

    content_type property

    The type of content being voted on

    score property

    Summed score of the content

    __eq__(other)

    Determine if this content votes is equal to another.

    Parameters:

    Name Type Description Default
    other (int, str, HTMLContentVotes)

    Object to compare to.

    required

    Returns:

    Name Type Description
    Comparison (bool, None)

    Did it fit the criteria?

    Source code in cocorum/scraping.py
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    def __eq__(self, other):
        """Determine if this content votes is equal to another.
    
    Args:
        other (int, str, HTMLContentVotes): Object to compare to.
    
    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """
    
        #Check for direct matches first
        if isinstance(other, int):
            return self.score == other
        if isinstance(other, str):
            return str(self) == other
    
        #Check for object attributes to match to
        if hasattr(other, "score"):
            #if hasattr(other, "content_id") and hasattr(other, "content_type"):
            #    return self.score, self.content_id, self.content_type == other.score, other.content_id, other.content_type
            return self.score == other.score
    
        #Check conversion to integer last
        if hasattr(other, "__int__"):
            return self.score == int(other)
    

    __int__()

    The integer form of the content votes

    Source code in cocorum/scraping.py
    199
    200
    201
    def __int__(self):
        """The integer form of the content votes"""
        return self.score
    

    __str__()

    The string form of the content votes

    Source code in cocorum/scraping.py
    203
    204
    205
    206
    def __str__(self):
        """The string form of the content votes"""
        #return self.score_formatted
        return str(self.score)
    

    HTMLObj

    Abstract object scraped from bs4 HTML

    Source code in cocorum/scraping.py
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    class HTMLObj:
        """Abstract object scraped from bs4 HTML"""
        def __init__(self, elem):
            """Abstract object scraped from bs4 HTML
    
        Args:
            elem (bs4.Tag): The BeautifulSoup element to base our data on.
            """
    
            self._elem = elem
    
        def __getitem__(self, key):
            """Get a key from the element attributes
    
        Args:
            key (str): A valid attribute name.
            """
    
            return self._elem.attrs[key]
    

    __getitem__(key)

    Get a key from the element attributes

    Parameters:

    Name Type Description Default
    key str

    A valid attribute name.

    required
    Source code in cocorum/scraping.py
    23
    24
    25
    26
    27
    28
    29
    30
    def __getitem__(self, key):
        """Get a key from the element attributes
    
    Args:
        key (str): A valid attribute name.
        """
    
        return self._elem.attrs[key]
    

    __init__(elem)

    Abstract object scraped from bs4 HTML

    Parameters:

    Name Type Description Default
    elem Tag

    The BeautifulSoup element to base our data on.

    required
    Source code in cocorum/scraping.py
    14
    15
    16
    17
    18
    19
    20
    21
    def __init__(self, elem):
        """Abstract object scraped from bs4 HTML
    
    Args:
        elem (bs4.Tag): The BeautifulSoup element to base our data on.
        """
    
        self._elem = elem
    

    HTMLPlaylist

    Bases: HTMLObj

    A playlist as obtained from HTML data

    Source code in cocorum/scraping.py
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    329
    330
    331
    332
    333
    334
    335
    336
    337
    338
    339
    340
    341
    342
    343
    344
    345
    346
    347
    348
    349
    350
    351
    352
    353
    354
    355
    356
    357
    358
    359
    360
    361
    362
    363
    364
    365
    366
    367
    368
    369
    370
    371
    372
    373
    374
    375
    376
    377
    378
    379
    380
    381
    382
    383
    384
    385
    class HTMLPlaylist(HTMLObj):
        """A playlist as obtained from HTML data"""
        def __init__(self, elem, scraper):
            """A playlist as obtained from HTML data.
    
        Args:
            elem (bs4.Tag): The playlist class = "thumbnail__grid-item" element.
            scraper (Scraper): The HTML scraper object that spawned us.
            """
    
            super().__init__(elem)
    
            #The Scraper object that created this one
            self.scraper = scraper
    
            #The binary data of our thumbnail
            self.__thumbnail = None
    
            #The loaded page of the playlist
            self.__pagesoup = None
    
        def __int__(self):
            """The playlist as an integer (it's ID in base 10)"""
            return self.playlist_id_b10
    
        def __str__(self):
            """The playlist as a string (it's ID in base 36)"""
            return self.playlist_id_b36
    
        def __eq__(self, other):
            """Determine if this playlist is equal to another.
    
        Args:
            other (int, str, HTMLPlaylist): Object to compare to.
    
        Returns:
            Comparison (bool, None): Did it fit the criteria?
            """
    
            #Check for direct matches first
            if isinstance(other, int):
                return self.playlist_id_b10 == other
            if isinstance(other, str):
                return str(other) == self.playlist_id_b36
    
            #Check for object attributes to match to
            if hasattr(other, "playlist_id"):
                return self.playlist_id_b10 == utils.ensure_b10(other.playlist_id)
    
            #Check conversion to integer last, in case another ID or something happens to match
            if hasattr(other, "__int__"):
                return self.playlist_id_b10 == int(other)
    
        @property
        def _pagesoup(self):
            """The loaded page of the playlist"""
            if not self.__pagesoup:
                self.__pagesoup = self.scraper.soup_request(self.url)
    
            return self.__pagesoup
    
        @property
        def thumbnail_url(self):
            """The url of the playlist's thumbnail image"""
            return self._elem.find("img", attrs = {"class" : "thumbnail__image"}).get("src")
    
        @property
        def thumbnail(self):
            """The playlist thumbnail as a binary string"""
            if not self.__thumbnail: #We never queried the thumbnail before
                response = requests.get(self.thumbnail_url, timeout = static.Delays.request_timeout)
                assert response.status_code == 200, "Status code " + str(response.status_code)
    
                self.__thumbnail = response.content
    
            return self.__thumbnail
    
        @property
        def _url_raw(self):
            """The URL of the playlist page (without Rumble base URL)"""
            return self._elem.find("a", attrs = {"class" : "playlist__name link"}).get("href")
    
        @property
        def url(self):
            """The URL of the playlist page """
            return static.URI.rumble_base + self._url_raw
    
        @property
        def playlist_id(self):
            """The numeric ID of the playlist in base 36"""
            return self._url_raw.split("/")[-1]
    
        @property
        def playlist_id_b36(self):
            """The numeric ID of the playlist in base 36"""
            return self.playlist_id
    
        @property
        def playlist_id_b10(self):
            """The numeric ID of the playlist in base 10"""
            return utils.base_36_to_10(self.playlist_id)
    
        @property
        def _channel_url_raw(self):
            """The URL of the channel the playlist under (without base URL)"""
            return self._elem.find("a", attrs = {"class" : "channel__link link"}).get("href")
    
        @property
        def channel_url(self):
            """The URL of the base user or channel the playlist under"""
            return static.URI.rumble_base + self._channel_url_raw
    
        @property
        def is_under_channel(self):
            """Is this playlist under a channel?"""
            return self._channel_url_raw.startswith("/c/")
    
        @property
        def title(self):
            """The title of the playlist"""
            return self._pagesoup.find("h1", attrs = {"class" : "playlist-control-panel__playlist-name"}).string.strip()
    
        @property
        def description(self):
            """The description of the playlist"""
            return self._pagesoup.find("div", attrs = {"class" : "playlist-control-panel__description"}).string.strip()
    
        @property
        def visibility(self):
            """The visibility of the playlist"""
            return self._pagesoup.find("span", attrs = {"class" : "playlist-control-panel__visibility-state"}).string.strip().lower()
    
        @property
        def num_items(self):
            """The number of items in the playlist"""
            #This is doable but I just don't care right now
            NotImplemented
    

    channel_url property

    The URL of the base user or channel the playlist under

    description property

    The description of the playlist

    is_under_channel property

    Is this playlist under a channel?

    num_items property

    The number of items in the playlist

    playlist_id property

    The numeric ID of the playlist in base 36

    playlist_id_b10 property

    The numeric ID of the playlist in base 10

    playlist_id_b36 property

    The numeric ID of the playlist in base 36

    thumbnail property

    The playlist thumbnail as a binary string

    thumbnail_url property

    The url of the playlist's thumbnail image

    title property

    The title of the playlist

    url property

    The URL of the playlist page

    visibility property

    The visibility of the playlist

    __eq__(other)

    Determine if this playlist is equal to another.

    Parameters:

    Name Type Description Default
    other (int, str, HTMLPlaylist)

    Object to compare to.

    required

    Returns:

    Name Type Description
    Comparison (bool, None)

    Did it fit the criteria?

    Source code in cocorum/scraping.py
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    def __eq__(self, other):
        """Determine if this playlist is equal to another.
    
    Args:
        other (int, str, HTMLPlaylist): Object to compare to.
    
    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """
    
        #Check for direct matches first
        if isinstance(other, int):
            return self.playlist_id_b10 == other
        if isinstance(other, str):
            return str(other) == self.playlist_id_b36
    
        #Check for object attributes to match to
        if hasattr(other, "playlist_id"):
            return self.playlist_id_b10 == utils.ensure_b10(other.playlist_id)
    
        #Check conversion to integer last, in case another ID or something happens to match
        if hasattr(other, "__int__"):
            return self.playlist_id_b10 == int(other)
    

    __init__(elem, scraper)

    A playlist as obtained from HTML data.

    Parameters:

    Name Type Description Default
    elem Tag

    The playlist class = "thumbnail__grid-item" element.

    required
    scraper Scraper

    The HTML scraper object that spawned us.

    required
    Source code in cocorum/scraping.py
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    def __init__(self, elem, scraper):
        """A playlist as obtained from HTML data.
    
    Args:
        elem (bs4.Tag): The playlist class = "thumbnail__grid-item" element.
        scraper (Scraper): The HTML scraper object that spawned us.
        """
    
        super().__init__(elem)
    
        #The Scraper object that created this one
        self.scraper = scraper
    
        #The binary data of our thumbnail
        self.__thumbnail = None
    
        #The loaded page of the playlist
        self.__pagesoup = None
    

    __int__()

    The playlist as an integer (it's ID in base 10)

    Source code in cocorum/scraping.py
    270
    271
    272
    def __int__(self):
        """The playlist as an integer (it's ID in base 10)"""
        return self.playlist_id_b10
    

    __str__()

    The playlist as a string (it's ID in base 36)

    Source code in cocorum/scraping.py
    274
    275
    276
    def __str__(self):
        """The playlist as a string (it's ID in base 36)"""
        return self.playlist_id_b36
    

    HTMLUserBadge

    Bases: HTMLObj

    A user badge as extracted from a bs4 HTML element

    Source code in cocorum/scraping.py
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    class HTMLUserBadge(HTMLObj):
        """A user badge as extracted from a bs4 HTML element"""
        def __init__(self, elem):
            """A user badge as extracted from a bs4 HTML element.
    
        Args:
            elem (bs4.Tag): The badge <img> element
            """
    
            super().__init__(elem)
            self.slug = elem.attrs["src"].split("/")[-1:elem.attrs["src"].rfind("_")]
            self.__icon = None
    
        def __eq__(self, other):
            """Check if this badge is equal to another.
    
        Args:
            other (str, HTMLUserBadge): Object to compare to.
    
        Returns:
            Comparison (bool, None): Did it fit the criteria?
            """
    
            #Check if the string is either our slug or our label in any language
            if isinstance(other, str):
                return other in (self.slug, self.label.values())
    
            #Check if the compared object has the same slug, if it has one
            if hasattr(other, "slug"):
                return self.slug == other.slug
    
        def __str__(self):
            """The chat user badge in string form"""
            return self.slug
    
        @property
        def label(self):
            """The string label of the badge in whatever language the Service.PHP agent used"""
            return self["title"]
    
        @property
        def icon_url(self):
            """The URL of the badge's icon"""
            return static.URI.rumble_base + self["src"]
    
        @property
        def icon(self):
            """The badge's icon as a bytestring"""
            if not self.__icon: #We never queried the icon before
                #TODO make the timeout configurable
                response = requests.get(self.icon_url, timeout = static.Delays.request_timeout)
                assert response.status_code == 200, "Status code " + str(response.status_code)
    
                self.__icon = response.content
    
            return self.__icon
    

    icon property

    The badge's icon as a bytestring

    icon_url property

    The URL of the badge's icon

    label property

    The string label of the badge in whatever language the Service.PHP agent used

    __eq__(other)

    Check if this badge is equal to another.

    Parameters:

    Name Type Description Default
    other (str, HTMLUserBadge)

    Object to compare to.

    required

    Returns:

    Name Type Description
    Comparison (bool, None)

    Did it fit the criteria?

    Source code in cocorum/scraping.py
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    def __eq__(self, other):
        """Check if this badge is equal to another.
    
    Args:
        other (str, HTMLUserBadge): Object to compare to.
    
    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """
    
        #Check if the string is either our slug or our label in any language
        if isinstance(other, str):
            return other in (self.slug, self.label.values())
    
        #Check if the compared object has the same slug, if it has one
        if hasattr(other, "slug"):
            return self.slug == other.slug
    

    __init__(elem)

    A user badge as extracted from a bs4 HTML element.

    Parameters:

    Name Type Description Default
    elem Tag

    The badge element

    required
    Source code in cocorum/scraping.py
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    def __init__(self, elem):
        """A user badge as extracted from a bs4 HTML element.
    
    Args:
        elem (bs4.Tag): The badge <img> element
        """
    
        super().__init__(elem)
        self.slug = elem.attrs["src"].split("/")[-1:elem.attrs["src"].rfind("_")]
        self.__icon = None
    

    __str__()

    The chat user badge in string form

    Source code in cocorum/scraping.py
    63
    64
    65
    def __str__(self):
        """The chat user badge in string form"""
        return self.slug
    

    HTMLVideo

    Bases: HTMLObj

    Video on a user or channel page as extracted from the page's HTML

    Source code in cocorum/scraping.py
    449
    450
    451
    452
    453
    454
    455
    456
    457
    458
    459
    460
    461
    462
    463
    464
    465
    466
    467
    468
    469
    470
    471
    472
    473
    474
    475
    476
    477
    478
    479
    480
    481
    482
    483
    484
    485
    486
    487
    488
    489
    490
    491
    492
    493
    494
    495
    496
    497
    498
    499
    500
    501
    502
    503
    504
    505
    506
    507
    508
    509
    510
    511
    512
    513
    514
    515
    516
    517
    518
    519
    520
    521
    522
    523
    524
    525
    526
    527
    528
    529
    530
    531
    532
    533
    534
    535
    536
    537
    538
    539
    540
    541
    class HTMLVideo(HTMLObj):
        """Video on a user or channel page as extracted from the page's HTML"""
        def __init__(self, elem):
            """Video on a user or channel page as extracted from the page's HTML.
    
        Args:
            elem (bs4.Tag): The class = "thumbnail__grid-item" video element.
            """
    
            super().__init__(elem)
    
            #The binary data of our thumbnail
            self.__thumbnail = None
    
        def __int__(self):
            """The video as an integer (it's numeric ID)"""
            return self.video_id_b10
    
        def __str__(self):
            """The video as a string (it's ID in base 36)"""
            return self.video_id_b36
    
        def __eq__(self, other):
            """Determine if this video is equal to another.
    
        Args:
            other (int, str, HTMLVideo): Object to compare to.
    
        Returns:
            Comparison (bool, None): Did it fit the criteria?
            """
    
            #Check for direct matches first
            if isinstance(other, int):
                return self.video_id_b10 == other
            if isinstance(other, str):
                return str(other) == self.video_id_b36
    
            #Check for object attributes to match to
            if hasattr(other, "video_id"):
                return self.video_id_b10 == utils.ensure_b10(other.video_id)
            if hasattr(other, "stream_id"):
                return self.video_id_b10 == utils.ensure_b10(other.stream_id)
    
            #Check conversion to integer last, in case another ID or something happens to match
            if hasattr(other, "__int__"):
                return self.video_id_b10 == int(other)
    
        @property
        def video_id(self):
            """The numeric ID of the video in base 10"""
            return int(self._elem.get("data-video-id"))
    
        @property
        def video_id_b10(self):
            """The numeric ID of the video in base 10"""
            return self.video_id
    
        @property
        def video_id_b36(self):
            """The numeric ID of the video in base 36"""
            return utils.base_10_to_36(self.video_id)
    
        @property
        def thumbnail_url(self):
            """The URL of the video's thumbnail image"""
            return self._elem.find("img", attrs = {"class" : "thumbnail__image"}).get("src")
    
        @property
        def thumbnail(self):
            """The video thumbnail as a binary string"""
            if not self.__thumbnail: #We never queried the thumbnail before
                response = requests.get(self.thumbnail_url, timeout = static.Delays.request_timeout)
                assert response.status_code == 200, "Status code " + str(response.status_code)
    
                self.__thumbnail = response.content
    
            return self.__thumbnail
    
        @property
        def video_url(self):
            """The URL of the video's viewing page"""
            return static.URI.rumble_base + self._elem.find("a", attrs = {"class" : "videostream__link link"}).get("href")
    
        @property
        def title(self):
            """The title of the video"""
            return self._elem.find("h3", attrs = {"class" : "thumbnail__title"}).get("title")
    
        @property
        def upload_date(self):
            """The time that the video was uploaded, in seconds since epoch"""
            return utils.parse_timestamp(self._elem.find("time", attrs = {"class" : "videostream__data--subitem videostream__time"}).get("datetime"))
    

    thumbnail property

    The video thumbnail as a binary string

    thumbnail_url property

    The URL of the video's thumbnail image

    title property

    The title of the video

    upload_date property

    The time that the video was uploaded, in seconds since epoch

    video_id property

    The numeric ID of the video in base 10

    video_id_b10 property

    The numeric ID of the video in base 10

    video_id_b36 property

    The numeric ID of the video in base 36

    video_url property

    The URL of the video's viewing page

    __eq__(other)

    Determine if this video is equal to another.

    Parameters:

    Name Type Description Default
    other (int, str, HTMLVideo)

    Object to compare to.

    required

    Returns:

    Name Type Description
    Comparison (bool, None)

    Did it fit the criteria?

    Source code in cocorum/scraping.py
    471
    472
    473
    474
    475
    476
    477
    478
    479
    480
    481
    482
    483
    484
    485
    486
    487
    488
    489
    490
    491
    492
    493
    494
    495
    def __eq__(self, other):
        """Determine if this video is equal to another.
    
    Args:
        other (int, str, HTMLVideo): Object to compare to.
    
    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """
    
        #Check for direct matches first
        if isinstance(other, int):
            return self.video_id_b10 == other
        if isinstance(other, str):
            return str(other) == self.video_id_b36
    
        #Check for object attributes to match to
        if hasattr(other, "video_id"):
            return self.video_id_b10 == utils.ensure_b10(other.video_id)
        if hasattr(other, "stream_id"):
            return self.video_id_b10 == utils.ensure_b10(other.stream_id)
    
        #Check conversion to integer last, in case another ID or something happens to match
        if hasattr(other, "__int__"):
            return self.video_id_b10 == int(other)
    

    __init__(elem)

    Video on a user or channel page as extracted from the page's HTML.

    Parameters:

    Name Type Description Default
    elem Tag

    The class = "thumbnail__grid-item" video element.

    required
    Source code in cocorum/scraping.py
    451
    452
    453
    454
    455
    456
    457
    458
    459
    460
    461
    def __init__(self, elem):
        """Video on a user or channel page as extracted from the page's HTML.
    
    Args:
        elem (bs4.Tag): The class = "thumbnail__grid-item" video element.
        """
    
        super().__init__(elem)
    
        #The binary data of our thumbnail
        self.__thumbnail = None
    

    __int__()

    The video as an integer (it's numeric ID)

    Source code in cocorum/scraping.py
    463
    464
    465
    def __int__(self):
        """The video as an integer (it's numeric ID)"""
        return self.video_id_b10
    

    __str__()

    The video as a string (it's ID in base 36)

    Source code in cocorum/scraping.py
    467
    468
    469
    def __str__(self):
        """The video as a string (it's ID in base 36)"""
        return self.video_id_b36
    

    Scraper

    Scraper for general information

    Source code in cocorum/scraping.py
    543
    544
    545
    546
    547
    548
    549
    550
    551
    552
    553
    554
    555
    556
    557
    558
    559
    560
    561
    562
    563
    564
    565
    566
    567
    568
    569
    570
    571
    572
    573
    574
    575
    576
    577
    578
    579
    580
    581
    582
    583
    584
    585
    586
    587
    588
    589
    590
    591
    592
    593
    594
    595
    596
    597
    598
    599
    600
    601
    602
    603
    604
    605
    606
    607
    608
    609
    610
    611
    612
    613
    614
    615
    616
    617
    618
    619
    620
    621
    622
    623
    624
    625
    626
    627
    628
    629
    630
    631
    632
    633
    634
    635
    636
    637
    638
    639
    640
    641
    642
    643
    644
    645
    646
    647
    648
    649
    650
    651
    652
    653
    654
    655
    656
    657
    658
    659
    660
    661
    662
    663
    664
    665
    666
    667
    668
    669
    670
    671
    672
    673
    674
    675
    676
    677
    678
    679
    680
    681
    682
    683
    684
    685
    686
    687
    688
    689
    690
    691
    692
    693
    694
    695
    696
    697
    698
    699
    700
    class Scraper:
        """Scraper for general information"""
        def __init__(self, servicephp):
            """Scraper for general information.
    
        Args:
            servicephp (ServicePHP): A ServicePHP instance, for authentication.
            """
    
            self.servicephp = servicephp
    
        @property
        def session_cookie(self):
            """The session cookie we are logged in with"""
            return self.servicephp.session_cookie
    
        @property
        def username(self):
            """Our username"""
            return self.servicephp.username
    
        def soup_request(self, url: str):
            """Make a GET request to a URL, and return HTML beautiful soup for scraping.
    
        Args:
            url (str): The URL to query.
    
        Returns:
            Soup (bs4.BeautifulSoup): The webpage at the URL, logged-in version.
            """
    
            r = requests.get(
                url,
                cookies = self.session_cookie,
                timeout = static.Delays.request_timeout,
                headers = static.RequestHeaders.user_agent,
                )
    
            assert r.status_code == 200, f"Fetching page {url} failed: {r}\n{r.text}"
            return bs4.BeautifulSoup(r.text, features = "html.parser")
    
        def get_muted_user_record(self, username: str = None):
            """Get the record IDs for mutes.
    
        Args:
            username (str): Username to find record ID for.
                Defaults to None.
    
        Returns:
            Record (int, dict): Either the single user's mute record ID, or a dict of all username:mute record ID pairs.
            """
    
            #The page we are on
            pagenum = 1
    
            #username : record ID
            record_ids = {}
    
            #While there are more pages
            while True:
                #Get the next page of mutes and search for mute buttons
                soup = self.soup_request(static.URI.mutes_page.format(page = pagenum))
                elems = soup.find_all("button", attrs = {"class" : "unmute_action button-small"})
    
                #We reached the last page
                if not elems:
                    break
    
                #Get the record IDs per username from each button
                for e in elems:
                    #We were searching for a specific username and found it
                    if username and e.attrs["data-username"] == username:
                        return e.attrs["data-record-id"]
    
                    record_ids[e.attrs["data-username"]] = int(e.attrs["data-record-id"])
    
                #Turn the page
                pagenum +=1
    
            #Only return record IDs if we weren't searching for a particular one
            if not username:
                return record_ids
    
            #We were searching for a user and did not find them
            return None
    
        def get_channels(self, username: str = None):
            """Get all channels under a username.
    
        Args:
            username (str): The username to get the channels under.
                Defaults to None, use our own username.
    
        Returns:
            Channels (list): List of HTMLChannel objects.
            """
    
            if not username:
                username = self.username
    
            #Get the page of channels and parse for them
            soup = self.soup_request(static.URI.channels_page.format(username = username))
            elems = soup.find_all("div", attrs = {"data-type" : "channel"})
            return [HTMLChannel(e) for e in elems]
    
        def get_videos(self, username = None, is_channel = False, max_num = None):
            """Get the videos under a user or channel.
    
        Args:
            username (str): The name of the user or channel to search under.
                Defaults to ourselves.
            is_channel (bool): Is this a channel instead of a userpage?
                Defaults to False.
            max_num (int): The maximum number of videos to retrieve, starting from the newest.
                Defaults to None, return all videos.
                Note, rounded up to the nearest page.
    
        Returns:
            Videos (list): List of HTMLVideo objects.
            """
    
            #default to the logged-in username
            if not username:
                username = self.username
    
            #If this is a channel username, we will need a slightly different URL
            uc = ("user", "c")[is_channel]
    
            #The base userpage URL currently has all their videos / livestreams on it
            url_start = f"{static.URI.rumble_base}/{uc}/{username}"
    
            #Start the loop with:
            #- no videos found yet
            #- the assumption that there will be new video elements
            #- a current page number of 1
            videos = []
            new_video_elems = True
            pagenum = 1
            while new_video_elems and (not max_num or len(videos) < max_num):
                #Get the next page of videos
                soup = self.soup_request(f"{url_start}?page={pagenum}")
    
                #Search for video listings
                new_video_elems = soup.find_all("div", attrs = {"class" : "videostream thumbnail__grid--item"})
    
                #We found some video listings
                if new_video_elems:
                    videos += [HTMLVideo(e) for e in new_video_elems]
    
                #Turn the page
                pagenum += 1
    
            return videos
    
        def get_playlists(self):
            """Get the playlists under the logged in user"""
            soup = self.soup_request(static.URI.playlists_page)
            return [HTMLPlaylist(elem, self) for elem in soup.find_all("div", attrs = {"class" : "playlist"})]
    

    The session cookie we are logged in with

    username property

    Our username

    __init__(servicephp)

    Scraper for general information.

    Parameters:

    Name Type Description Default
    servicephp ServicePHP

    A ServicePHP instance, for authentication.

    required
    Source code in cocorum/scraping.py
    545
    546
    547
    548
    549
    550
    551
    552
    def __init__(self, servicephp):
        """Scraper for general information.
    
    Args:
        servicephp (ServicePHP): A ServicePHP instance, for authentication.
        """
    
        self.servicephp = servicephp
    

    get_channels(username=None)

    Get all channels under a username.

    Parameters:

    Name Type Description Default
    username str

    The username to get the channels under. Defaults to None, use our own username.

    None

    Returns:

    Name Type Description
    Channels list

    List of HTMLChannel objects.

    Source code in cocorum/scraping.py
    629
    630
    631
    632
    633
    634
    635
    636
    637
    638
    639
    640
    641
    642
    643
    644
    645
    646
    def get_channels(self, username: str = None):
        """Get all channels under a username.
    
    Args:
        username (str): The username to get the channels under.
            Defaults to None, use our own username.
    
    Returns:
        Channels (list): List of HTMLChannel objects.
        """
    
        if not username:
            username = self.username
    
        #Get the page of channels and parse for them
        soup = self.soup_request(static.URI.channels_page.format(username = username))
        elems = soup.find_all("div", attrs = {"data-type" : "channel"})
        return [HTMLChannel(e) for e in elems]
    

    get_muted_user_record(username=None)

    Get the record IDs for mutes.

    Parameters:

    Name Type Description Default
    username str

    Username to find record ID for. Defaults to None.

    None

    Returns:

    Name Type Description
    Record (int, dict)

    Either the single user's mute record ID, or a dict of all username:mute record ID pairs.

    Source code in cocorum/scraping.py
    584
    585
    586
    587
    588
    589
    590
    591
    592
    593
    594
    595
    596
    597
    598
    599
    600
    601
    602
    603
    604
    605
    606
    607
    608
    609
    610
    611
    612
    613
    614
    615
    616
    617
    618
    619
    620
    621
    622
    623
    624
    625
    626
    627
    def get_muted_user_record(self, username: str = None):
        """Get the record IDs for mutes.
    
    Args:
        username (str): Username to find record ID for.
            Defaults to None.
    
    Returns:
        Record (int, dict): Either the single user's mute record ID, or a dict of all username:mute record ID pairs.
        """
    
        #The page we are on
        pagenum = 1
    
        #username : record ID
        record_ids = {}
    
        #While there are more pages
        while True:
            #Get the next page of mutes and search for mute buttons
            soup = self.soup_request(static.URI.mutes_page.format(page = pagenum))
            elems = soup.find_all("button", attrs = {"class" : "unmute_action button-small"})
    
            #We reached the last page
            if not elems:
                break
    
            #Get the record IDs per username from each button
            for e in elems:
                #We were searching for a specific username and found it
                if username and e.attrs["data-username"] == username:
                    return e.attrs["data-record-id"]
    
                record_ids[e.attrs["data-username"]] = int(e.attrs["data-record-id"])
    
            #Turn the page
            pagenum +=1
    
        #Only return record IDs if we weren't searching for a particular one
        if not username:
            return record_ids
    
        #We were searching for a user and did not find them
        return None
    

    get_playlists()

    Get the playlists under the logged in user

    Source code in cocorum/scraping.py
    697
    698
    699
    700
    def get_playlists(self):
        """Get the playlists under the logged in user"""
        soup = self.soup_request(static.URI.playlists_page)
        return [HTMLPlaylist(elem, self) for elem in soup.find_all("div", attrs = {"class" : "playlist"})]
    

    get_videos(username=None, is_channel=False, max_num=None)

    Get the videos under a user or channel.

    Parameters:

    Name Type Description Default
    username str

    The name of the user or channel to search under. Defaults to ourselves.

    None
    is_channel bool

    Is this a channel instead of a userpage? Defaults to False.

    False
    max_num int

    The maximum number of videos to retrieve, starting from the newest. Defaults to None, return all videos. Note, rounded up to the nearest page.

    None

    Returns:

    Name Type Description
    Videos list

    List of HTMLVideo objects.

    Source code in cocorum/scraping.py
    648
    649
    650
    651
    652
    653
    654
    655
    656
    657
    658
    659
    660
    661
    662
    663
    664
    665
    666
    667
    668
    669
    670
    671
    672
    673
    674
    675
    676
    677
    678
    679
    680
    681
    682
    683
    684
    685
    686
    687
    688
    689
    690
    691
    692
    693
    694
    695
    def get_videos(self, username = None, is_channel = False, max_num = None):
        """Get the videos under a user or channel.
    
    Args:
        username (str): The name of the user or channel to search under.
            Defaults to ourselves.
        is_channel (bool): Is this a channel instead of a userpage?
            Defaults to False.
        max_num (int): The maximum number of videos to retrieve, starting from the newest.
            Defaults to None, return all videos.
            Note, rounded up to the nearest page.
    
    Returns:
        Videos (list): List of HTMLVideo objects.
        """
    
        #default to the logged-in username
        if not username:
            username = self.username
    
        #If this is a channel username, we will need a slightly different URL
        uc = ("user", "c")[is_channel]
    
        #The base userpage URL currently has all their videos / livestreams on it
        url_start = f"{static.URI.rumble_base}/{uc}/{username}"
    
        #Start the loop with:
        #- no videos found yet
        #- the assumption that there will be new video elements
        #- a current page number of 1
        videos = []
        new_video_elems = True
        pagenum = 1
        while new_video_elems and (not max_num or len(videos) < max_num):
            #Get the next page of videos
            soup = self.soup_request(f"{url_start}?page={pagenum}")
    
            #Search for video listings
            new_video_elems = soup.find_all("div", attrs = {"class" : "videostream thumbnail__grid--item"})
    
            #We found some video listings
            if new_video_elems:
                videos += [HTMLVideo(e) for e in new_video_elems]
    
            #Turn the page
            pagenum += 1
    
        return videos
    

    soup_request(url)

    Make a GET request to a URL, and return HTML beautiful soup for scraping.

    Parameters:

    Name Type Description Default
    url str

    The URL to query.

    required

    Returns:

    Name Type Description
    Soup BeautifulSoup

    The webpage at the URL, logged-in version.

    Source code in cocorum/scraping.py
    564
    565
    566
    567
    568
    569
    570
    571
    572
    573
    574
    575
    576
    577
    578
    579
    580
    581
    582
    def soup_request(self, url: str):
        """Make a GET request to a URL, and return HTML beautiful soup for scraping.
    
    Args:
        url (str): The URL to query.
    
    Returns:
        Soup (bs4.BeautifulSoup): The webpage at the URL, logged-in version.
        """
    
        r = requests.get(
            url,
            cookies = self.session_cookie,
            timeout = static.Delays.request_timeout,
            headers = static.RequestHeaders.user_agent,
            )
    
        assert r.status_code == 200, f"Fetching page {url} failed: {r}\n{r.text}"
        return bs4.BeautifulSoup(r.text, features = "html.parser")
    

    cocorum.jsonhandles

    JSON handles

    Abstract classes for handling JSON data. S.D.G.

    JSONObj

    Abstract class for handling a JSON data block as an object

    Source code in cocorum/jsonhandles.py
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    class JSONObj():
        """Abstract class for handling a JSON data block as an object"""
        def __init__(self, jsondata):
            """Abstract class for handling a JSON data block as an object.
    
        Args:
            jsondata (dict): The JSON data block of an API object.
            """
    
            self._jsondata = jsondata
    
        def __getitem__(self, key):
            """Get a key from the JSON"""
            return self._jsondata[key]
    
        @property
        def get(self):
            """Get a key from the JSON with fallback"""
            return self._jsondata.get
    

    get property

    Get a key from the JSON with fallback

    __getitem__(key)

    Get a key from the JSON

    Source code in cocorum/jsonhandles.py
    21
    22
    23
    def __getitem__(self, key):
        """Get a key from the JSON"""
        return self._jsondata[key]
    

    __init__(jsondata)

    Abstract class for handling a JSON data block as an object.

    Parameters:

    Name Type Description Default
    jsondata dict

    The JSON data block of an API object.

    required
    Source code in cocorum/jsonhandles.py
    12
    13
    14
    15
    16
    17
    18
    19
    def __init__(self, jsondata):
        """Abstract class for handling a JSON data block as an object.
    
    Args:
        jsondata (dict): The JSON data block of an API object.
        """
    
        self._jsondata = jsondata
    

    JSONUserAction

    Bases: JSONObj

    Abstract class for Rumble JSON user actions

    Source code in cocorum/jsonhandles.py
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    class JSONUserAction(JSONObj):
        """Abstract class for Rumble JSON user actions"""
        def __init__(self, jsondata):
            """Abstract class for Rumble JSON user actions.
    
        Args:
            jsondata (dict): The JSON block for a single Rumble user action.
            """
    
            JSONObj.__init__(self, jsondata)
            self.__profile_pic = None
    
        def __eq__(self, other):
            """Is this user equal to another?
    
        Args:
            other (str, JSONUserAction): Object to compare to.
    
        Returns:
            Comparison (bool, None): Did it fit the criteria?
            """
    
            #Check if the compared string is our username, or base 36 user ID if we have one
            if isinstance(other, str):
                #We have a base 36 user ID
                if hasattr(self, "user_id_b36"):
                    return other in (self.username, self.user_id_b36)
    
                #We only have our username
                return self.username == other
    
            #Check if the compared object has a username and if it matches our own
            if hasattr(other, "username"):
                return self.username == other.username
    
            #Check if the compared object has a user ID in base 36 and if it matches our own, if we have one
            if hasattr(self, "user_id_b36") and hasattr(other, "user_id_b36"):
                return self.user_id_b36 == other.user_id_b36
    
        def __str__(self):
            """Follower as a string"""
            return self.username
    
        @property
        def username(self):
            """The username"""
            return self["username"]
    
        @property
        def profile_pic_url(self):
            """The user's profile picture URL"""
            return self["profile_pic_url"]
    
        @property
        def profile_pic(self):
            """The user's profile picture as a bytes string"""
            if not self.profile_pic_url: #The profile picture is blank
                return b''
    
            if not self.__profile_pic: #We never queried the profile pic before
                response = requests.get(self.profile_pic_url, timeout = static.Delays.request_timeout)
                assert response.status_code == 200, "Status code " + str(response.status_code)
    
                self.__profile_pic = response.content
    
            return self.__profile_pic
    

    profile_pic property

    The user's profile picture as a bytes string

    profile_pic_url property

    The user's profile picture URL

    username property

    The username

    __eq__(other)

    Is this user equal to another?

    Parameters:

    Name Type Description Default
    other (str, JSONUserAction)

    Object to compare to.

    required

    Returns:

    Name Type Description
    Comparison (bool, None)

    Did it fit the criteria?

    Source code in cocorum/jsonhandles.py
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    def __eq__(self, other):
        """Is this user equal to another?
    
    Args:
        other (str, JSONUserAction): Object to compare to.
    
    Returns:
        Comparison (bool, None): Did it fit the criteria?
        """
    
        #Check if the compared string is our username, or base 36 user ID if we have one
        if isinstance(other, str):
            #We have a base 36 user ID
            if hasattr(self, "user_id_b36"):
                return other in (self.username, self.user_id_b36)
    
            #We only have our username
            return self.username == other
    
        #Check if the compared object has a username and if it matches our own
        if hasattr(other, "username"):
            return self.username == other.username
    
        #Check if the compared object has a user ID in base 36 and if it matches our own, if we have one
        if hasattr(self, "user_id_b36") and hasattr(other, "user_id_b36"):
            return self.user_id_b36 == other.user_id_b36
    

    __init__(jsondata)

    Abstract class for Rumble JSON user actions.

    Parameters:

    Name Type Description Default
    jsondata dict

    The JSON block for a single Rumble user action.

    required
    Source code in cocorum/jsonhandles.py
    32
    33
    34
    35
    36
    37
    38
    39
    40
    def __init__(self, jsondata):
        """Abstract class for Rumble JSON user actions.
    
    Args:
        jsondata (dict): The JSON block for a single Rumble user action.
        """
    
        JSONObj.__init__(self, jsondata)
        self.__profile_pic = None
    

    __str__()

    Follower as a string

    Source code in cocorum/jsonhandles.py
    69
    70
    71
    def __str__(self):
        """Follower as a string"""
        return self.username
    

    cocorum.utils

    Rumble API utilities

    This submodule provides some utilities for working with the APIs S.D.G.

    MD5Ex

    MD5 extended hashing utilities

    Source code in cocorum/utils.py
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    class MD5Ex:
        """MD5 extended hashing utilities"""
    
        @staticmethod
        def hash(message: str) -> str:
            """Hash a string.
    
        Args:
            message (str): Message to hash.
    
        Returns:
            Hash (str): The hex digest hash result.
            """
    
            #Actually, we can except bytes, but if we got string, encode the string
            if isinstance(message, str):
                message = message.encode(static.Misc.text_encoding)
    
            return hashlib.md5(message).hexdigest()
    
        @staticmethod
        def hash_stretch(password: str, salt: str, iterations: int = 1024) -> str:
            """Stretch-hash a password with a salt.
    
        Args:
            password (str): The password to hash.
            salt (str): The salt to add to the password.
            iterations (int): Number of times to stretch the hashing.
    
        Returns:
            Hash (str): The completed stretched hash.
            """
    
            #Start with the salt and password together
            message = (salt + password).encode(static.Misc.text_encoding)
    
            #Make one hash of it
            current = MD5Ex.hash(message)
    
            #Then keep re-adding the password and re-hashing
            for _ in range(iterations):
                current = MD5Ex.hash(current + password)
    
            return current
    

    hash(message) staticmethod

    Hash a string.

    Parameters:

    Name Type Description Default
    message str

    Message to hash.

    required

    Returns:

    Name Type Description
    Hash str

    The hex digest hash result.

    Source code in cocorum/utils.py
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    @staticmethod
    def hash(message: str) -> str:
        """Hash a string.
    
    Args:
        message (str): Message to hash.
    
    Returns:
        Hash (str): The hex digest hash result.
        """
    
        #Actually, we can except bytes, but if we got string, encode the string
        if isinstance(message, str):
            message = message.encode(static.Misc.text_encoding)
    
        return hashlib.md5(message).hexdigest()
    

    hash_stretch(password, salt, iterations=1024) staticmethod

    Stretch-hash a password with a salt.

    Parameters:

    Name Type Description Default
    password str

    The password to hash.

    required
    salt str

    The salt to add to the password.

    required
    iterations int

    Number of times to stretch the hashing.

    1024

    Returns:

    Name Type Description
    Hash str

    The completed stretched hash.

    Source code in cocorum/utils.py
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    @staticmethod
    def hash_stretch(password: str, salt: str, iterations: int = 1024) -> str:
        """Stretch-hash a password with a salt.
    
    Args:
        password (str): The password to hash.
        salt (str): The salt to add to the password.
        iterations (int): Number of times to stretch the hashing.
    
    Returns:
        Hash (str): The completed stretched hash.
        """
    
        #Start with the salt and password together
        message = (salt + password).encode(static.Misc.text_encoding)
    
        #Make one hash of it
        current = MD5Ex.hash(message)
    
        #Then keep re-adding the password and re-hashing
        for _ in range(iterations):
            current = MD5Ex.hash(current + password)
    
        return current
    

    badges_to_glyph_string(badges)

    Convert a list of badges into a string of glyphs.

    Parameters:

    Name Type Description Default
    badges list

    A list of str or objects with str method that are valid badge slugs.

    required

    Returns:

    Name Type Description
    Glyphs str

    The badge list as a UTF-8 glyph string, uses ? for unknown badges.

    Source code in cocorum/utils.py
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    def badges_to_glyph_string(badges) -> str:
        """Convert a list of badges into a string of glyphs.
    
        Args:
            badges (list): A list of str or objects with __str__ method that are valid badge slugs.
    
        Returns:
            Glyphs (str): The badge list as a UTF-8 glyph string, uses ? for unknown badges.
            """
    
        out = ""
        for badge in badges:
            badge = str(badge)
            if badge in static.Misc.badges_as_glyphs:
                out += static.Misc.badges_as_glyphs[badge]
            else:
                out += "?"
        return out
    

    base_10_to_36(b10)

    Convert a base 10 number to base 36.

    Parameters:

    Name Type Description Default
    b10 int

    The base 10 number.

    required

    Returns:

    Name Type Description
    B36 str

    The same number in base 36.

    Source code in cocorum/utils.py
     85
     86
     87
     88
     89
     90
     91
     92
     93
     94
     95
     96
     97
     98
     99
    100
    101
    102
    def base_10_to_36(b10) -> str:
        """Convert a base 10 number to base 36.
    
        Args:
            b10 (int): The base 10 number.
    
        Returns:
            B36 (str): The same number in base 36.
            """
    
        b10 = int(b10)
        b36 = ""
        base_len = len(static.Misc.base36)
        while b10:
            b36 = static.Misc.base36[b10 % base_len] + b36
            b10 //= base_len
    
        return b36
    

    base_36_and_10(num, assume_10=False)

    Take a base 36 or base 10 number, and return both base 36 and 10.

    Parameters:

    Name Type Description Default
    num (int, str)

    The number in either base 10 or 36.

    required
    assume_10 bool

    If the number is a string but looks like base 10, should we assume it is? Defaults to False.

    False

    Returns:

    Name Type Description
    B36 str

    The number in base 36.

    B10 int

    The number in base 10.

    Source code in cocorum/utils.py
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    def base_36_and_10(num, assume_10 = False):
        """Take a base 36 or base 10 number, and return both base 36 and 10.
    
        Args:
            num (int, str): The number in either base 10 or 36.
            assume_10 (bool): If the number is a string but looks like base 10, should we assume it is?
                Defaults to False.
    
        Returns:
            B36 (str): The number in base 36.
            B10 (int): The number in base 10.
            """
    
        return ensure_b36(num, assume_10), ensure_b10(num, assume_10)
    

    base_36_to_10(b36)

    Convert a base 36 number to base 10.

    Parameters:

    Name Type Description Default
    b36 str

    The base 36 number.

    required

    Returns:

    Name Type Description
    B10 int

    The same number in base 10.

    Source code in cocorum/utils.py
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    def base_36_to_10(b36) -> int:
        """Convert a base 36 number to base 10.
    
        Args:
            b36 (str): The base 36 number.
    
        Returns:
            B10 (int): The same number in base 10.
            """
    
        return int(str(b36), 36)
    

    calc_password_hashes(password, salts)

    Hash a password for Rumble authentication.

    Parameters:

    Name Type Description Default
    password str

    The password to hash.

    required
    salts iter

    The three salts to use for hashing.

    required

    Returns:

    Name Type Description
    Hashes iter

    The three results of hashing.

    Source code in cocorum/utils.py
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    def calc_password_hashes(password: str, salts):
        """Hash a password for Rumble authentication.
    
        Args:
            password (str): The password to hash.
            salts (iter): The three salts to use for hashing.
    
        Returns:
            Hashes (iter): The three results of hashing.
            """
    
        #Stretch-hash the password with the first salt
        stretched1 = MD5Ex.hash_stretch(password, salts[0], 128)
    
        #Add the second salt to that result, and hash one more time
        final_hash1 = MD5Ex.hash(stretched1 + salts[1])
    
        #Stretch-hash the password with the third salt
        stretched2 = MD5Ex.hash_stretch(password, salts[2], 128)
    
        return final_hash1, stretched2, salts[1]
    

    ensure_b10(num, assume_10=False)

    No matter wether a number is base 36 or 10, return 10.

    Parameters:

    Name Type Description Default
    num (int, str)

    The number in either base 10 or 36.

    required
    assume_10 bool

    If the number is a string but looks like base 10, should we assume it is? Defaults to False.

    False

    Returns:

    Name Type Description
    Number int

    The number in base 10.

    Source code in cocorum/utils.py
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    def ensure_b10(num, assume_10 = False) -> int:
        """No matter wether a number is base 36 or 10, return 10.
    
        Args:
            num (int, str): The number in either base 10 or 36.
            assume_10 (bool): If the number is a string but looks like base 10, should we assume it is?
                Defaults to False.
    
        Returns:
            Number (int): The number in base 10.
            """
    
        #It is base 10 or has an integer conversion method
        if isinstance(num, int) or hasattr(num, "__int__"):
            return int(num)
    
        #It is a string or has a string conversion attribute
        if isinstance(num, str) or hasattr(num, "__str__"):
            num = str(num)
    
            #The string number is in base 10
            if num.isnumeric() and assume_10:
                return base_10_to_36(num), int(num)
    
        #It is base 36:
        return base_36_to_10(num)
    

    ensure_b36(num, assume_10=False)

    No matter wether a number is base 36 or 10, return 36.

    Parameters:

    Name Type Description Default
    num (int, str)

    The number in either base 10 or 36.

    required
    assume_10 bool

    If the number is a string but looks like base 10, should we assume it is? Defaults to False.

    False

    Returns:

    Name Type Description
    Number str

    The number in base 36.

    Source code in cocorum/utils.py
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    def ensure_b36(num, assume_10 = False) -> str:
        """No matter wether a number is base 36 or 10, return 36.
    
        Args:
            num (int, str): The number in either base 10 or 36.
            assume_10 (bool): If the number is a string but looks like base 10, should we assume it is?
                Defaults to False.
    
        Returns:
            Number (str): The number in base 36.
            """
    
        #It is base 10
        if isinstance(num, int) or hasattr(num, "__int__"):
            return base_10_to_36(int(num))
    
        #It is a string or has a string conversion attribute
        if isinstance(num, str) or hasattr(num, "__str__"):
            num = str(num)
    
            #The string number is in base 10
            if num.isnumeric() and assume_10:
                return base_10_to_36(num)
    
        #It is base 36:
        return num
    

    form_timestamp(seconds, suffix='+00:00')

    Form a Rumble timestamp.

    Parameters:

    Name Type Description Default
    seconds float

    Timestamp in seconds since Epoch, UTC.

    required

    Returns:

    Name Type Description
    Timestamp str

    The same timestamp value, in Rumble's API format.

    Source code in cocorum/utils.py
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    def form_timestamp(seconds: float, suffix = "+00:00") -> str:
        """Form a Rumble timestamp.
    
        Args:
            seconds (float): Timestamp in seconds since Epoch, UTC.
    
        Returns:
            Timestamp (str): The same timestamp value, in Rumble's API format.
            """
    
        return time.strftime(static.Misc.timestamp_format, time.gmtime(seconds)) + suffix
    

    generate_request_id()

    Generate a UUID for API requests

    Returns:

    Name Type Description
    UUID str

    Random base64 encoded UUID.

    Source code in cocorum/utils.py
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    def generate_request_id() -> str:
        """Generate a UUID for API requests
    
        Returns:
            UUID (str): Random base64 encoded UUID.
            """
    
        random_uuid = uuid.uuid4().bytes + uuid.uuid4().bytes
        b64_encoded = base64.b64encode(random_uuid).decode(static.Misc.text_encoding)
        return b64_encoded.rstrip('=')[:43]
    

    options_check(url, method, origin=static.URI.rumble_base, cookies={}, params={})

    Check of we are allowed to do method on url via an options request

    Parameters:

    Name Type Description Default
    url str

    The URL to check at.

    required
    method str

    The HTTP method to check permission for.

    required
    origin str

    The origin header of the options request. Defaults to static.URI.rumble_base

    rumble_base
    cookies dict

    Cookie dict to use in the request. Defaults to no cookies.

    {}
    params dict

    Parameters to use in the request. Defaults to no parameters.

    {}

    Returns:

    Name Type Description
    Result bool

    Is the HTTP method allowed at the URL?

    Source code in cocorum/utils.py
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    def options_check(url: str, method: str, origin = static.URI.rumble_base, cookies: dict = {}, params: dict = {}) -> bool:
        """Check of we are allowed to do method on url via an options request
    
        Args:
            url (str): The URL to check at.
            method (str): The HTTP method to check permission for.
            origin (str): The origin header of the options request.
                Defaults to static.URI.rumble_base
            cookies (dict): Cookie dict to use in the request.
                Defaults to no cookies.
            params (dict): Parameters to use in the request.
                Defaults to no parameters.
    
        Returns:
            Result (bool): Is the HTTP method allowed at the URL?
            """
    
        r = requests.options(
            url,
            headers = {
                'Access-Control-Request-Method' : method.upper(),
                'Access-Control-Request-Headers' : 'content-type',
                'Origin' : origin,
                },
            cookies = cookies,
            params = params,
            timeout = static.Delays.request_timeout,
            )
        return r.status_code == 200
    

    parse_timestamp(timestamp)

    Parse a Rumble timestamp.

    Parameters:

    Name Type Description Default
    timestamp str

    Timestamp in Rumble's API format.

    required

    Returns:

    Name Type Description
    Timestamp float

    The same timestamp value, in seconds since Epoch, UTC.

    Source code in cocorum/utils.py
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    def parse_timestamp(timestamp: str) -> float:
        """Parse a Rumble timestamp.
    
        Args:
            timestamp (str): Timestamp in Rumble's API format.
    
        Returns:
            Timestamp (float): The same timestamp value, in seconds since Epoch, UTC.
            """
    
        #Trims off the 6 TODO characters at the end
        return calendar.timegm(time.strptime(timestamp[:-6], static.Misc.timestamp_format))
    

    Test if a session cookie dict is valid.

    Parameters:

    Name Type Description Default
    session_cookie dict

    The session cookie dict to test.

    required

    Returns:

    Name Type Description
    Result bool

    Is the cookie dict valid?

    Source code in cocorum/utils.py
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    def test_session_cookie(session_cookie: dict) -> bool:
        """Test if a session cookie dict is valid.
    
        Args:
            session_cookie (dict): The session cookie dict to test.
    
        Returns:
            Result (bool): Is the cookie dict valid?
            """
    
        r = requests.get(static.URI.login_test,
                cookies = session_cookie,
                headers = static.RequestHeaders.user_agent,
                timeout = static.Delays.request_timeout,
            )
    
        assert r.status_code == 200, f"Testing session token failed: {r}"
    
        title = r.text.split("<title>")[1].split("</title>")[0]
    
        #If the session token is invalid, it won't log us in and "Login" will still be shown
        return "Login" not in title
    

    cocorum.static

    Cocorum static variable definitions

    Provides various data that, if changed, would need to change globally.

    S.D.G.

    Delays

    Various times for delays and waits

    Source code in cocorum/static.py
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    class Delays:
        """Various times for delays and waits"""
    
        #How long to wait before giving up on a network request, in seconds
        request_timeout = 20
    
        #How long to reuse old data from the main API, in seconds
        api_refresh_default = 10
    
        #Minimum refresh rate for the main API, as defined by Rumble
        api_refresh_minimum = 5
    

    Message

    For chat messages

    Source code in cocorum/static.py
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    class Message:
        """For chat messages"""
    
        #Maximum chat message length
        max_len = 200
    
        #How long to wait between sending messages
        send_cooldown = 3
    
        #Prefix Rumble uses for native command
        command_prefix = "/"
    

    Misc

    No idea where else to put this data

    Source code in cocorum/static.py
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    class Misc:
        """No idea where else to put this data"""
        #Numerical base that the stream ID is in
        base36 = "0123456789abcdefghijklmnopqrstuvwxyz"
    
        #Dictionary of badge slugs mapped to UTF-8 glyphs
        badges_as_glyphs = {
            "verified" : "✅",
            "admin" : "👑",
            "moderator" : "🛡",
            "premium" : "🗲",
            "locals" : "♖",
            "recurring_subscription" : "♖",
            "locals_supporter" : "⛋",
            "whale-grey" : "🐳",
            "whale-yellow" : "🐳",
            "whale-blue" : "🐳",
            }
    
        #Encoding for all text-bytes conversions
        text_encoding = "utf-8"
    
        #Size of chat badge icons to retrieve, only valid one has long been the string 48
        badge_icon_size = "48"
    
        #Rumble timestamp format, not including the 6 TODO characters at the end
        timestamp_format = "%Y-%m-%dT%H:%M:%S"
    
        #Key of the session token within the session cookie dict
        session_token_key = "u_s"
    
        class ContentTypes:
            """Types of content that can be rumbled on"""
    
            #A video or livestream
            video = 1
    
            #A comment
            comment = 2
    

    ContentTypes

    Types of content that can be rumbled on

    Source code in cocorum/static.py
    139
    140
    141
    142
    143
    144
    145
    146
    class ContentTypes:
        """Types of content that can be rumbled on"""
    
        #A video or livestream
        video = 1
    
        #A comment
        comment = 2
    

    RequestHeaders

    Headers for various HTTPrequests

    Source code in cocorum/static.py
     8
     9
    10
    11
    12
    13
    14
    15
    class RequestHeaders:
        """Headers for various HTTPrequests"""
    
        #Header with a fake user-agent string
        user_agent = {"User-Agent" : "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36"}
    
        #Headers for the SSE chat API
        sse_api = {'Accept': 'text/event-stream'}
    

    StaticAPIEndpoints

    API endpoints that don't change and shouldn't trigger a refresh

    Source code in cocorum/static.py
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    class StaticAPIEndpoints:
        """API endpoints that don't change and shouldn't trigger a refresh"""
        #Endpoints of the main API
        main = [
            "user_id",
            "username",
            "channel_id",
            "channel_name",
            ]
    
        #Endpoints of the stream subobject
        stream = [
            "id",
            "created_on"
            ]
    

    URI

    URIs to various Rumble services

    Source code in cocorum/static.py
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    class URI:
        """URIs to various Rumble services"""
    
        #Base URL to Rumble's website, for URLs that are relative to it
        rumble_base = "https://rumble.com"
    
        #Test the session token by sending it here and checking the title
        login_test = rumble_base + "/login.php"
    
        #Webpage with all the mutes on it, format with page number
        mutes_page = rumble_base + "/account/moderation/muting?pg={page}"
    
        #Channels under a user, format with username
        channels_page = rumble_base + "/user/{username}/channels"
    
        #The logged-in user's playlist page
        playlists_page = rumble_base + "/my-library/playlists"
    
        #The Service.PHP API
        servicephp = "https://rumble.com/service.php"
    
        #The video upload PHP
        uploadphp = "https://web18.rumble.com/upload.php"
    
        class ChatAPI:
            """URIs of the chat API"""
    
            #Rumble's internal chat URL for a stream, format this string with a stream_id_b10
            base = "https://web7.rumble.com/chat/api/chat/{stream_id_b10}"
    
            #SSE stream of chat events
            sse_stream = base + "/stream"
    
            #Message actions
            message = base + "/message"
    
            #Chat commands (does not use the base)
            command = "https://rumble.com/chat/command"
    

    ChatAPI

    URIs of the chat API

    Source code in cocorum/static.py
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    class ChatAPI:
        """URIs of the chat API"""
    
        #Rumble's internal chat URL for a stream, format this string with a stream_id_b10
        base = "https://web7.rumble.com/chat/api/chat/{stream_id_b10}"
    
        #SSE stream of chat events
        sse_stream = base + "/stream"
    
        #Message actions
        message = base + "/message"
    
        #Chat commands (does not use the base)
        command = "https://rumble.com/chat/command"
    

    Upload

    Data relating to uploading videos

    Source code in cocorum/static.py
     97
     98
     99
    100
    101
    102
    103
    104
    105
    106
    class Upload:
        """Data relating to uploading videos"""
        #Size of upload chunks, not sure if this can be changed
        chunksz = 10000000
    
        #Upload API version to use
        api_ver = "1.3"
    
        #Maximum upload size is 15GB as stated by Rumble
        max_filesize = 15 * (1000 ** 3)
    

    S.D.G.