diff --git a/bot/domain.py b/bot/domain.py index e625fb2..3fba1f7 100644 --- a/bot/domain.py +++ b/bot/domain.py @@ -11,7 +11,7 @@ 🧑🏻‍🎨 Author: {author} 📅 Created: {created} 👀 Views: {views} -👍🏻 Likes: {likes} +👍🏻 Likes: {likes} 👎🏻 Dislikes: {dislikes} 📕 Description: {description}\n """ @@ -95,6 +95,7 @@ class Post: description: typing.Optional[str] = None views: typing.Optional[int] = None likes: typing.Optional[int] = None + dislikes: typing.Optional[int] = None buffer: typing.Optional[io.BytesIO] = None spoiler: bool = False created: typing.Optional[datetime.datetime] = None @@ -109,8 +110,9 @@ def __str__(self) -> str: author=self.author or '❌', created=utils.date_to_human_format(self.created) if self.created else '❌', description=description if not self.spoiler else f'||{description}||', - views=utils.number_to_human_format(self.views) if self.views else '❌', - likes=utils.number_to_human_format(self.likes) if self.likes else '❌', + views=utils.number_to_human_format(self.views) if self.views is not None else '❌', + likes=utils.number_to_human_format(self.likes) if self.likes is not None else '❌', + dislikes=utils.number_to_human_format(self.dislikes) if self.dislikes is not None else '❌', ) def set_format(self, fmt: typing.Optional[str]) -> None: diff --git a/bot/integrations/reddit/client.py b/bot/integrations/reddit/client.py index 2a9c2e2..813845d 100644 --- a/bot/integrations/reddit/client.py +++ b/bot/integrations/reddit/client.py @@ -8,6 +8,7 @@ import uuid import asyncpraw +import asyncpraw.models import redvid import requests from RedDownloader import RedDownloader as reddit_downloader @@ -107,7 +108,7 @@ async def _hydrate_post(self, post: domain.Post) -> bool: return self._hydrate_post_no_login(post) try: - submission = await self.client.submission(url=post.url) + submission: asyncpraw.models.Submission = await self.client.submission(url=post.url) except praw_exceptions.InvalidURL: # Hack for new reddit urls generated in mobile app # Does another request, which redirects to the correct url @@ -120,7 +121,7 @@ async def _hydrate_post(self, post: domain.Post) -> bool: post.author = submission.author post.description = f'{submission.title}{content}' - post.likes = submission.score + post.likes, post.dislikes = self._calculate_votes(upvotes=submission.score, ratio=submission.upvote_ratio) post.spoiler = submission.over_18 or submission.spoiler post.created = datetime.datetime.fromtimestamp(submission.created_utc).astimezone() @@ -196,3 +197,8 @@ def _download_and_merge_gallery(self, url: str) -> typing.Optional[io.BytesIO]: def _is_nsfw(url: str) -> bool: content = str(requests.get(url, timeout=base.DEFAULT_TIMEOUT).content) return 'nsfw":true' in content or 'isNsfw":true' in content + + @staticmethod + def _calculate_votes(upvotes: int, ratio: float) -> typing.Tuple[int, int]: + downvotes = (upvotes / ratio) - upvotes + return (upvotes, int(downvotes)) diff --git a/bot/integrations/youtube/client.py b/bot/integrations/youtube/client.py index e89ddc0..741851b 100644 --- a/bot/integrations/youtube/client.py +++ b/bot/integrations/youtube/client.py @@ -1,6 +1,7 @@ import io import typing +import aiohttp import pytubefix as pytube from django.conf import settings @@ -10,6 +11,9 @@ from bot import logger from bot.integrations import base from bot.integrations.youtube import config +from bot.integrations.youtube import types + +LIKES_API_URL_TEMPLATE = 'https://returnyoutubedislikeapi.com/votes?videoId={video_id}' class YoutubeClientSingleton(base.BaseClientSingleton): @@ -25,12 +29,15 @@ def _create_instance(cls) -> None: cls._INSTANCE = base.MISSING return - cls._INSTANCE = YoutubeClient() + cls._INSTANCE = YoutubeClient(fetch_likes=conf.external_likes_api) class YoutubeClient(base.BaseClient): INTEGRATION = constants.Integration.YOUTUBE + def __init__(self, fetch_likes: bool) -> None: + self.fetch_likes = fetch_likes + async def get_integration_data(self, url: str) -> typing.Tuple[constants.Integration, str, typing.Optional[int]]: return self.INTEGRATION, url.strip('/').split('?')[0].split('/')[-1], None @@ -47,6 +54,11 @@ async def get_post(self, url: str) -> domain.Post: spoiler=vid.age_restricted is True, ) + if self.fetch_likes: + likes = await self._get_likes(video_id=vid.video_id) + post.likes = likes.likes + post.dislikes = likes.dislikes + vid.streams.filter(progressive=True, file_extension='mp4').order_by( 'resolution' ).desc().first().stream_to_buffer(post.buffer) @@ -55,3 +67,8 @@ async def get_post(self, url: str) -> domain.Post: async def get_comments(self, url: str, n: int = 5) -> typing.List[domain.Comment]: raise exceptions.NotSupportedError('get_comments') + + async def _get_likes(self, video_id: str) -> types.Likes: + async with aiohttp.ClientSession() as session: + async with session.get(url=LIKES_API_URL_TEMPLATE.format(video_id=video_id)) as resp: + return types.Likes.model_validate(await resp.json()) diff --git a/bot/integrations/youtube/config.py b/bot/integrations/youtube/config.py index 62ec48e..17eb4fd 100644 --- a/bot/integrations/youtube/config.py +++ b/bot/integrations/youtube/config.py @@ -2,6 +2,4 @@ class YoutubeConfig(base.BaseClientConfig): - """ - No additional settings for Youtube integration - """ + external_likes_api: bool = False diff --git a/bot/integrations/youtube/types.py b/bot/integrations/youtube/types.py new file mode 100644 index 0000000..db33468 --- /dev/null +++ b/bot/integrations/youtube/types.py @@ -0,0 +1,22 @@ +import datetime + +import pydantic +import pydantic.alias_generators + + +class Likes(pydantic.BaseModel): + model_config = pydantic.ConfigDict( + alias_generator=pydantic.alias_generators.to_camel, + populate_by_name=True, + from_attributes=True, + ) + + id: str + date_created: datetime.datetime + likes: int + raw_dislikes: int + raw_likes: int + dislikes: int + rating: int + view_count: int + deleted: bool diff --git a/bot/migrations/0002_post_dislikes.py b/bot/migrations/0002_post_dislikes.py new file mode 100644 index 0000000..bcef689 --- /dev/null +++ b/bot/migrations/0002_post_dislikes.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2024-10-17 07:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bot', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='post', + name='dislikes', + field=models.BigIntegerField(default=None, null=True), + ), + ] diff --git a/bot/models/server.py b/bot/models/server.py index a6924a5..b2cc8a5 100644 --- a/bot/models/server.py +++ b/bot/models/server.py @@ -154,6 +154,10 @@ class Post(models.Model): null=True, default=None, ) + dislikes = models.BigIntegerField( + null=True, + default=None, + ) spoiler = models.BooleanField( null=False, default=False, diff --git a/bot/repository.py b/bot/repository.py index b253177..ef5c752 100644 --- a/bot/repository.py +++ b/bot/repository.py @@ -199,6 +199,7 @@ def get_post( description=post.description, views=post.views, likes=post.likes, + dislikes=post.dislikes, buffer=io.BytesIO(post.blob) if post.blob else None, spoiler=post.spoiler, created=post.posted_at, @@ -223,6 +224,7 @@ def save_post( description=post.description, views=post.views, likes=post.likes, + dislikes=post.dislikes, spoiler=post.spoiler, posted_at=post.created, blob=post.read_buffer(), diff --git a/conf/settings_base.py b/conf/settings_base.py index e501933..3bc9f47 100644 --- a/conf/settings_base.py +++ b/conf/settings_base.py @@ -252,6 +252,7 @@ }, 'youtube': { 'enabled': False, + 'external_likes_api': False, }, 'threads': { 'enable': False,