about to filter

This commit is contained in:
Nathan Rashleigh committed 2024-04-08 18:44:40 +10:00
1 parent 557020a824
commit d0e2be7d12
4 files changed
+101 -119

No files matched your search

+15 -3
View File
@@ -1,9 +1,15 @@
from urllib.parse import urlparse
from uuid import UUID from uuid import UUID
import os import os
from typing import Any, Optional from typing import Any, Optional
from urllib3.util import parse_url
from urllib3.util import Url
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy import UniqueConstraint
from sqlalchemy import BigInteger
from sqlalchemy.orm import Mapped from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
@@ -18,7 +24,7 @@ class Attachment(Base):
# can be multiple attachments on a msg # can be multiple attachments on a msg
# so we make our own ids for attachments # so we make our own ids for attachments
id: Mapped[UUID] = mapped_column(primary_key=True) id: Mapped[UUID] = mapped_column(primary_key=True)
msg_id: Mapped[int] = mapped_column(nullable=False, index=True) msg_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
url: Mapped[str] url: Mapped[str]
@@ -27,15 +33,21 @@ class Attachment(Base):
channel: Mapped[dict[str, Any]] channel: Mapped[dict[str, Any]]
vision: Mapped[Optional[dict[str, Any]]] = mapped_column(default=None) vision: Mapped[Optional[dict[str, Any]]] = mapped_column(default=None)
__table_args__ = (UniqueConstraint('msg_id', 'url', name='ix_attachments_msg_id_url_uc'),)
def file_details(self) -> tuple[str, str]: def file_details(self) -> tuple[str, str]:
return os.path.splitext(os.path.basename(self.url)) u = urlparse(self.url)
if not u.path:
raise RuntimeError(f"attachment url missing path {self.url}")
return os.path.splitext(os.path.basename(u.path))
def object_name(self) -> str: def object_name(self) -> str:
print(self.id)
(filename, ext) = self.file_details() (filename, ext) = self.file_details()
if self.convert_to_webp(): if self.convert_to_webp():
return f"{self.id}.webp" return f"{self.id}.webp"
return f"{id}{ext}" return f"{self.id}{ext}"
def convert_to_webp(self) -> bool: def convert_to_webp(self) -> bool:
(filename, ext) = self.file_details() (filename, ext) = self.file_details()
+77 -72
View File
@@ -1,38 +1,31 @@
import os from logging import warn
import asyncio
import json
import itertools
from urllib3.util import parse_url
from pathlib import Path
from io import BytesIO
from dataclasses import dataclass
from uuid import uuid4, UUID
import aiohttp import aiohttp
import asyncio
import itertools
import json
import meilisearch
import os
from aiobotocore.session import get_session as aiobotocore_get_session from aiobotocore.session import get_session as aiobotocore_get_session
from types_aiobotocore_s3.client import S3Client
from PIL import Image
from dotenv import dotenv_values, load_dotenv
from google.cloud import vision as google_vision
from google.protobuf.json_format import MessageToDict, MessageToJson
from google.cloud.vision_v1 import types
import meilisearch
from urllib3.util import Url, url
from sqlalchemy import create_engine
from db.migrate import migrate_db
from db.attachment import Attachment from db.attachment import Attachment
from db.meme import Meme from db.meme import Meme
from db.migrate import migrate_db
from dotenv import dotenv_values, load_dotenv
from google.cloud import vision as google_vision
from google.cloud.vision_v1 import types
from io import BytesIO
from PIL import Image
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from types_aiobotocore_s3.client import S3Client
from urllib3.util import parse_url
from urllib3.util import Url
from uuid import uuid4
# need to change the env for the google api calls # need to change the env for the google api calls
load_dotenv() load_dotenv()
config = dotenv_values(".env") config = dotenv_values(".env")
DATA_DIR = './data/memestream'
MEILI_INDEX = 'memes' MEILI_INDEX = 'memes'
MEILI_URL = 'http://localhost:7700' MEILI_URL = 'http://localhost:7700'
@@ -41,23 +34,13 @@ CHANNEL_IDS = [
1095060717117374687, # aella memes 1095060717117374687, # aella memes
] ]
STORAGE_BUCKET = config['STORAGE_BUCKET']
assert STORAGE_BUCKET
headers = { headers = {
'Authorization': config['DISCORD_AUTH'], 'Authorization': config['DISCORD_AUTH'],
} }
# TODO
# [ ] Backfill last N images from a channel
# [ ] Set oldest_img_id for channel
# [ ] save img as json + imagefile
# [ ] check extensions (ignore mp4 maybe)
@dataclass
class MemeDocument:
id: UUID
url: Url
text: str
tags: list[str]
def detect_text(url, vision): def detect_text(url, vision):
request = { request = {
'image': { 'source': { 'image_uri': url } }, 'image': { 'source': { 'image_uri': url } },
@@ -75,15 +58,21 @@ def filter_message_img_urls(messages):
, messages) , messages)
return [item for group in url_lists for item in group] return [item for group in url_lists for item in group]
async def download_channel_attachments(session, storage, vision, channel_id): async def process_channel(
http_session: aiohttp.ClientSession,
storage: S3Client,
vision: google_vision.ImageAnnotatorClient,
db_session_maker: sessionmaker[Session],
channel_id: int
):
async def fetch_channel_messages(channel_id): async def fetch_channel_messages(channel_id):
url = f"https://discord.com/api/v9/channels/{channel_id}/messages" url = f"https://discord.com/api/v9/channels/{channel_id}/messages"
async with session.get(url, headers=headers) as res: async with http_session.get(url, headers=headers) as res:
return await res.json() return await res.json()
async def fetch_guild_details(guild_id): async def fetch_guild_details(guild_id):
url = f"https://discord.com/api/v9/guilds/{guild_id}" url = f"https://discord.com/api/v9/guilds/{guild_id}"
async with session.get(url, headers=headers) as res: async with http_session.get(url, headers=headers) as res:
data = await res.json() data = await res.json()
keys_to_remove = ['emojis', 'stickers', 'roles'] keys_to_remove = ['emojis', 'stickers', 'roles']
@@ -93,38 +82,28 @@ async def download_channel_attachments(session, storage, vision, channel_id):
async def fetch_channel(channel_id): async def fetch_channel(channel_id):
url = f"https://discord.com/api/v9/channels/{channel_id}" url = f"https://discord.com/api/v9/channels/{channel_id}"
async with session.get(url, headers=headers) as res: async with http_session.get(url, headers=headers) as res:
return await res.json() return await res.json()
async def download_attachment(attachment: Attachment):
async def upload_object(object_name, body): async with http_session.get(attachment.url) as response:
storage_bucket = config['STORAGE_BUCKET']
if not storage_bucket:
raise RuntimeError(f"config missing STORAGE_BUCKET")
return await storage.put_object(Bucket=storage_bucket, Key=object_name, Body=body)
async def download_attachment(
session: aiohttp.ClientSession,
storage: S3Client,
attachment: Attachment
):
async with session.get(attachment.url) as response:
if response.status == 200: if response.status == 200:
# make a webp conversion for obvious image types
res_bytes = await response.read()
(filename, ext) = attachment.file_details() (filename, ext) = attachment.file_details()
object_name = attachment.object_name() object_name = attachment.object_name()
print(f"uploading object_name={object_name} filename={filename} ext={ext}")
print(f"uploading object_name={object_name} filename={filename} ext={ext}")
res_bytes = await response.read()
# make a webp conversion for obvious image types
if attachment.convert_to_webp(): if attachment.convert_to_webp():
# have to read the whole img before converting to webp
image = Image.open(BytesIO(res_bytes)) image = Image.open(BytesIO(res_bytes))
webp_output = BytesIO() webp_output = BytesIO()
image.save(webp_output, format='WebP') image.save(webp_output, format='WebP')
webp_output.seek(0) webp_output.seek(0)
return await upload_object(object_name=object_name, body=webp_output) return await storage.put_object(Bucket=STORAGE_BUCKET, Key=object_name, Body=webp_output)
return await upload_object(object_name=object_name, body=res_bytes) return await storage.put_object(Bucket=STORAGE_BUCKET, Key=object_name, Body=res_bytes)
def create_attachments(message, channel, guild): def create_attachments(message, channel, guild):
urls = [parse_url(attachment['url']) for attachment in message['attachments']] urls = [parse_url(attachment['url']) for attachment in message['attachments']]
@@ -164,6 +143,28 @@ async def download_channel_attachments(session, storage, vision, channel_id):
# super wack flatmap and filter # super wack flatmap and filter
attachments = list(filter(None, itertools.chain.from_iterable(attachment_groups))) attachments = list(filter(None, itertools.chain.from_iterable(attachment_groups)))
db_session = db_session_maker()
# filter attachments that already exist in the db
# dirty way is just check if msg_id exists in db, don't care about specific attachments
# ok now we will try and upload each attachment
# for the ones that succeed we will add them to our session to be stored
async def upload_and_persist(attachment: Attachment):
try:
await download_attachment(attachment=attachment)
# this is kinda dodge, the exception will catch if we fail to add to session
db_session.add(attachment)
except Exception as e:
warn(e)
warn(f'deleting attachment upload {attachment.object_name()}')
await storage.delete_object(Bucket=STORAGE_BUCKET, Key=attachment.object_name())
attachments = [upload_and_persist(attachment=a) for a in attachments]
await asyncio.gather(*attachments)
db_session.commit()
# NOW # NOW
# ------------------- # -------------------
# insert attachment # insert attachment
@@ -207,29 +208,33 @@ async def main():
db_engine = create_engine(db_url, echo=True) db_engine = create_engine(db_url, echo=True)
migrate_db(db_engine, db_url) migrate_db(db_engine, db_url)
db_session_maker = sessionmaker(bind=db_engine)
aioboto = aiobotocore_get_session() aioboto = aiobotocore_get_session()
async with aioboto.create_client( async with aioboto.create_client(
's3', 's3',
endpoint_url=config['STORAGE_URL'], endpoint_url=config['STORAGE_URL'],
aws_access_key_id=config['STORAGE_ACCESS_KEY'], aws_access_key_id=config['STORAGE_ACCESS_KEY'],
aws_secret_access_key=config['STORAGE_SECRET_KEY'], aws_secret_access_key=config['STORAGE_SECRET_KEY'],
) as storage: ) as storage, aiohttp.ClientSession() as http_session:
storage: S3Client storage: S3Client
storage_bucket = config['STORAGE_BUCKET']
assert storage_bucket
# Make the bucket if it doesn't exist. # Make the bucket if it doesn't exist.
exists = await check_bucket_exists(storage, storage_bucket) exists = await check_bucket_exists(storage, STORAGE_BUCKET)
if not exists: if not exists:
await storage.create_bucket(Bucket=storage_bucket) await storage.create_bucket(Bucket=STORAGE_BUCKET)
print("Created bucket", storage_bucket) print("Created bucket", STORAGE_BUCKET)
else: else:
print("Bucket", storage_bucket, "already exists") print("Bucket", STORAGE_BUCKET, "already exists")
async with aiohttp.ClientSession() as session: channels = [process_channel(http_session, storage, vision, db_session_maker, channel_id) for channel_id in CHANNEL_IDS]
channels = [download_channel_attachments(session, storage, vision, channel_id) for channel_id in CHANNEL_IDS] memes = await asyncio.gather(*channels)
memes = await asyncio.gather(*channels)
asyncio.run(main()) asyncio.run(main())
# TODO
# [ ] Backfill last N images from a channel
# [ ] Set oldest_img_id for channel
# logger
@@ -1,8 +1,8 @@
"""Create Memes and Attachments table """Create Memes and Attachments table
Revision ID: e04bee35feab Revision ID: 50b5b3980665
Revises: Revises:
Create Date: 2024-04-08 10:45:47.138438 Create Date: 2024-04-08 18:30:57.222252
""" """
from typing import Sequence, Union from typing import Sequence, Union
@@ -12,7 +12,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = 'e04bee35feab' revision: str = '50b5b3980665'
down_revision: Union[str, None] = None down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@@ -22,17 +22,18 @@ def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('attachments', op.create_table('attachments',
sa.Column('id', sa.Uuid(), nullable=False), sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('msg_id', sa.Integer(), nullable=False), sa.Column('msg_id', sa.BigInteger(), nullable=False),
sa.Column('attributes', sa.JSON(), nullable=False), sa.Column('url', sa.String(), nullable=False),
sa.Column('vision', sa.JSON(), nullable=False),
sa.Column('message', sa.JSON(), nullable=False), sa.Column('message', sa.JSON(), nullable=False),
sa.Column('guild', sa.JSON(), nullable=False), sa.Column('guild', sa.JSON(), nullable=False),
sa.Column('channel', sa.JSON(), nullable=False), sa.Column('channel', sa.JSON(), nullable=False),
sa.PrimaryKeyConstraint('id') sa.Column('vision', sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('msg_id', 'url', name='ix_attachments_msg_id_url_uc')
) )
op.create_index(op.f('ix_attachments_msg_id'), 'attachments', ['msg_id'], unique=False) op.create_index(op.f('ix_attachments_msg_id'), 'attachments', ['msg_id'], unique=False)
op.create_table('memes', op.create_table('memes',
sa.Column('id', sa.Uuid(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('url', sa.String(), nullable=False), sa.Column('url', sa.String(), nullable=False),
sa.Column('text', sa.String(), nullable=False), sa.Column('text', sa.String(), nullable=False),
sa.Column('tags', sa.JSON(), nullable=False), sa.Column('tags', sa.JSON(), nullable=False),
@@ -1,36 +0,0 @@
"""allow vision to be nullable
Revision ID: 9159293e4f30
Revises: e04bee35feab
Create Date: 2024-04-08 10:50:21.447942
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '9159293e4f30'
down_revision: Union[str, None] = 'e04bee35feab'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('attachments', 'vision',
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=True)
op.drop_column('attachments', 'attributes')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('attachments', sa.Column('attributes', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=False))
op.alter_column('attachments', 'vision',
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=False)
# ### end Alembic commands ###