feat: remove !h and make the bot have an in-memory dict of greeted users instead of using the backoff service

This commit is contained in:
cătălin 2025-02-26 11:53:18 +01:00
commit b2185f4174
No known key found for this signature in database
52 changed files with 404 additions and 353 deletions

View file

@ -29,9 +29,10 @@ WORKDIR "$APP_PATH"
COPY --chown=$USERNAME pyproject.toml uv.lock Makefile README.md ./ COPY --chown=$USERNAME pyproject.toml uv.lock Makefile README.md ./
COPY --chown=$USERNAME src/ src/
RUN uv sync RUN uv sync
COPY --chown=$USERNAME src/ src/
COPY --chown=$USERNAME migrations/ migrations/ COPY --chown=$USERNAME migrations/ migrations/

View file

@ -16,7 +16,7 @@ tests:
uv run coverage xml uv run coverage xml
serve: serve:
uv run python -m src.huesoporro.main uv run python -m src.apps.httpapi.litestar.main
build: build:
docker build . -t git.roboces.dev/catalin/$(PROJECT_NAME):$(PROJECT_TAG) --target $(PROJECT_TARGET) docker build . -t git.roboces.dev/catalin/$(PROJECT_NAME):$(PROJECT_TAG) --target $(PROJECT_TARGET)

View file

@ -15,10 +15,10 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes # This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version. # to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/) # Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.2.9 version: 0.3.0
# This is the version number of the application being deployed. This version number should be # This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to # incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using. # follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes. # It is recommended to use it with quotes.
appVersion: "0.2.9" appVersion: "0.3.0"

View file

@ -11,7 +11,7 @@ image:
# This sets the pull policy for images. # This sets the pull policy for images.
pullPolicy: Always pullPolicy: Always
# Overrides the image tag whose default is the chart appVersion. # Overrides the image tag whose default is the chart appVersion.
tag: "0.2.9" tag: "0.3.0"
# This is for the secretes for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ # This is for the secretes for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
imagePullSecrets: [] imagePullSecrets: []

View file

@ -10,7 +10,6 @@
languages.python.version = "3.12.8"; languages.python.version = "3.12.8";
enterShell = '' enterShell = ''
uv sync
''; '';
dotenv.enable = true; dotenv.enable = true;

View file

@ -1,7 +1,7 @@
[project] [project]
name = "huesoporro" name = "huesoporro"
version = "0.2.9" version = "0.3.0"
description = "Misc Twitch bots" description = "Misc Twitch bot"
readme = "README.md" readme = "README.md"
authors = [ authors = [
{ name = "185504a9", email = "catalin@roboces.dev" } { name = "185504a9", email = "catalin@roboces.dev" }
@ -25,6 +25,13 @@ dependencies = [
"discord-py>=2.4.0", "discord-py>=2.4.0",
] ]
[project.scripts]
huesoporro = "apps.cli.typer.main:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.uv] [tool.uv]
dev-dependencies = [ dev-dependencies = [
"mypy>=1.13.0", "mypy>=1.13.0",
@ -46,7 +53,8 @@ module = [
"caribou.migrate", "caribou.migrate",
"twitchio", "twitchio",
"twitchio.ext", "twitchio.ext",
"gtts" "gtts",
"yt_dlp"
] ]
ignore_missing_imports = true ignore_missing_imports = true
@ -60,3 +68,9 @@ extend-ignore = ["S101", "ISC002", "COM812", "ISC001", "EM101", "EM102"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function" asyncio_default_fixture_loop_scope = "function"
[dependency-groups]
cli = [
"typer>=0.15.1",
"yt-dlp>=2025.1.26",
]

View file

@ -0,0 +1,24 @@
from pathlib import Path
from loguru import logger
from typer import Typer
from huesoporro.actions.import_from_vod import ImportFromVODAction
from huesoporro.settings import Settings
from huesoporro.svc.clean_cc_svc import CleanCCSvc
from huesoporro.svc.download_closed_captions import DownloadClosedCaptionsSvc
app = Typer()
@app.command()
def import_vod_cc(channel_name: str, youtube_url: str, db_path: Path | None = None):
logger.info(f"Importing VOD closed captions for {channel_name} from {youtube_url}")
s = Settings.get(db_filepath=db_path)
import_from_vod_action = ImportFromVODAction(
download_closed_captions_svc=DownloadClosedCaptionsSvc(),
clean_cc_svc=CleanCCSvc(),
s=s,
)
for cc_filepath in import_from_vod_action.run(channel_name, youtube_url):
logger.info(f"Closed captions imported from {cc_filepath}")

View file

View file

View file

@ -1,16 +1,17 @@
from litestar import Request from litestar import Request
from litestar.exceptions import HTTPException from litestar.exceptions import HTTPException
from src.huesoporro.actions.authenticate import AuthenticateAction from huesoporro.actions.authenticate import AuthenticateAction
from src.huesoporro.actions.get_user_by_jwt import GetUserByJWTAction from huesoporro.actions.get_user_by_jwt import GetUserByJWTAction
from src.huesoporro.infra.authenticator import TwitchAuthenticator from huesoporro.infra.authenticator import TwitchAuthenticator
from src.huesoporro.infra.db import Database from huesoporro.infra.db import Database
from src.huesoporro.infra.repos import UserRepo from huesoporro.infra.repos import UserRepo
from src.huesoporro.models import User from huesoporro.libs.db import MarkovDatabase
from src.huesoporro.settings import Settings from huesoporro.models import User
from src.huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc from huesoporro.settings import Settings
from src.huesoporro.svc.get_sentences_svc import SentencesGetterSvc from huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc
from src.huesoporro.svc.store_settings import ChatbotSettingsStorerSvc from huesoporro.svc.store import SentenceStorerSvc
from huesoporro.svc.store_settings import ChatbotSettingsStorerSvc
def get_settings() -> Settings: def get_settings() -> Settings:
@ -53,8 +54,8 @@ async def store_chatbot_settings_svc(db: Database):
return ChatbotSettingsStorerSvc(db=db) return ChatbotSettingsStorerSvc(db=db)
async def get_sentences_svc(db: Database): async def get_sentences_storer_svc(db: MarkovDatabase):
return SentencesGetterSvc(db=db) return SentenceStorerSvc(db=db)
async def get_user_repo(s: Settings): async def get_user_repo(s: Settings):

View file

@ -6,37 +6,35 @@ from litestar.exceptions import HTTPException
from litestar.static_files import StaticFilesConfig from litestar.static_files import StaticFilesConfig
from litestar.template import TemplateConfig from litestar.template import TemplateConfig
from src.huesoporro.api.dependencies import ( from apps.httpapi.litestar.dependencies import (
authenticate, authenticate,
get_authenticate_action, get_authenticate_action,
get_authenticator, get_authenticator,
get_chatbot_settings_svc, get_chatbot_settings_svc,
get_db, get_db,
get_get_user_by_jwt_action, get_get_user_by_jwt_action,
get_sentences_svc, get_sentences_storer_svc,
get_settings, get_settings,
get_user_repo, get_user_repo,
store_chatbot_settings_svc, store_chatbot_settings_svc,
) )
from src.huesoporro.api.errors import ( from apps.httpapi.litestar.errors import (
after_exception_handler, after_exception_handler,
http_exception_handler, http_exception_handler,
httpx_status_error_handler, httpx_status_error_handler,
) )
from src.huesoporro.api.routes.api import ( from apps.httpapi.litestar.routes.api import (
get_bot_settings, get_bot_settings,
get_bot_status, get_bot_status,
get_index, get_index,
get_sentences,
get_tts_overlay, get_tts_overlay,
get_tts_permalink, get_tts_permalink,
manage_bot, manage_bot,
save_bot_settings, save_bot_settings,
save_new_sentence,
) )
from src.huesoporro.api.routes.auth import get_code, login from apps.httpapi.litestar.routes.auth import get_code, login
from src.huesoporro.bot import BotsManager from huesoporro.bot import BotsManager
from src.huesoporro.settings import Settings from huesoporro.settings import Settings
@get("/healthz") @get("/healthz")
@ -57,8 +55,6 @@ def create_app():
get_bot_status, get_bot_status,
save_bot_settings, save_bot_settings,
get_bot_settings, get_bot_settings,
get_sentences,
save_new_sentence,
], ],
static_files_config=( static_files_config=(
StaticFilesConfig( StaticFilesConfig(
@ -87,7 +83,7 @@ def create_app():
"bm": Provide(BotsManager, use_cache=True), "bm": Provide(BotsManager, use_cache=True),
"gbs": Provide(get_chatbot_settings_svc), "gbs": Provide(get_chatbot_settings_svc),
"sbs": Provide(store_chatbot_settings_svc), "sbs": Provide(store_chatbot_settings_svc),
"sgs": Provide(get_sentences_svc), "sss": Provide(get_sentences_storer_svc),
"authenticator": Provide(get_authenticator), "authenticator": Provide(get_authenticator),
"authenticate_action": Provide(get_authenticate_action), "authenticate_action": Provide(get_authenticate_action),
"user_repo": Provide(get_user_repo), "user_repo": Provide(get_user_repo),

View file

@ -1,14 +1,14 @@
from typing import Literal from typing import Literal
from litestar import MediaType, Response, get, post, put from litestar import MediaType, Response, get, put
from litestar.datastructures import UploadFile
from litestar.response import Template from litestar.response import Template
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
from src.huesoporro.bot import BotsManager from huesoporro.bot import BotsManager
from src.huesoporro.models import ChatbotSettings, User from huesoporro.models import ChatbotSettings, User
from src.huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc from huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc
from src.huesoporro.svc.get_sentences_svc import SentencesGetterSvc from huesoporro.svc.store_settings import ChatbotSettingsStorerSvc
from src.huesoporro.svc.store_settings import ChatbotSettingsStorerSvc
class ManageBotDTO(BaseModel): class ManageBotDTO(BaseModel):
@ -16,6 +16,13 @@ class ManageBotDTO(BaseModel):
channel_name: str | None = None channel_name: str | None = None
class ImportTextFileDTO(BaseModel):
file: UploadFile
channel_name: str
model_config = ConfigDict(arbitrary_types_allowed=True)
@get( @get(
"/tts", "/tts",
media_type=MediaType.HTML, media_type=MediaType.HTML,
@ -97,17 +104,3 @@ async def save_bot_settings(
) -> dict: ) -> dict:
await sbs.run(user=user, bot_settings=data) await sbs.run(user=user, bot_settings=data)
return {"status": "ok"} return {"status": "ok"}
@get("/sentences")
async def get_sentences(user: User, sgs: SentencesGetterSvc) -> Template:
sentences = await sgs.run(user=user)
return Template(
template_name="sentences.html",
context={"sentences": [sentence.model_dump() for sentence in sentences]},
)
@post("/api/v1/sentences")
async def save_new_sentence(user: User, data: dict) -> dict:
return {"id": 54, "sentence": data["sentence"]}

View file

@ -4,8 +4,8 @@ from litestar import MediaType, get
from litestar.datastructures.cookie import Cookie from litestar.datastructures.cookie import Cookie
from litestar.response import Redirect, Template from litestar.response import Redirect, Template
from src.huesoporro.actions.authenticate import AuthenticateAction from huesoporro.actions.authenticate import AuthenticateAction
from src.huesoporro.settings import Settings from huesoporro.settings import Settings
@get(path="/o/code") @get(path="/o/code")

View file

@ -1,9 +1,9 @@
from pydantic import BaseModel from pydantic import BaseModel
from src.huesoporro.infra.authenticator import TwitchAuthenticator from huesoporro.infra.authenticator import TwitchAuthenticator
from src.huesoporro.infra.repos import UserRepo from huesoporro.infra.repos import UserRepo
from src.huesoporro.models import User from huesoporro.models import User
from src.huesoporro.settings import Settings from huesoporro.settings import Settings
class AuthenticateAction(BaseModel): class AuthenticateAction(BaseModel):

View file

@ -1,7 +1,7 @@
from pydantic import BaseModel from pydantic import BaseModel
from src.huesoporro.models import Quote from huesoporro.models import Quote
from src.huesoporro.svc.get_random_quote import RandomQuoteGetterSvc from huesoporro.svc.get_random_quote import RandomQuoteGetterSvc
class GetRandomQuoteAction(BaseModel): class GetRandomQuoteAction(BaseModel):

View file

@ -1,10 +1,10 @@
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from src.huesoporro.infra.authenticator import TwitchAuthenticator from huesoporro.infra.authenticator import TwitchAuthenticator
from src.huesoporro.infra.repos import UserRepo from huesoporro.infra.repos import UserRepo
from src.huesoporro.models import User from huesoporro.models import User
from src.huesoporro.settings import Settings from huesoporro.settings import Settings
class GetUserByJWTAction(BaseModel): class GetUserByJWTAction(BaseModel):

View file

@ -0,0 +1,34 @@
from collections.abc import Generator
from pathlib import Path
from pydantic import BaseModel, Field
from huesoporro.libs.db import MarkovDatabase
from huesoporro.settings import Settings
from huesoporro.svc.clean_cc_svc import CleanCCSvc
from huesoporro.svc.download_closed_captions import DownloadClosedCaptionsSvc
from huesoporro.svc.store import SentenceStorerSvc
class ImportFromVODAction(BaseModel):
download_closed_captions_svc: DownloadClosedCaptionsSvc
clean_cc_svc: CleanCCSvc
s: Settings = Field(default_factory=Settings.get)
ignore_lines: set[str] = {
"WEBVTT",
"Kind: captions",
"Language: en",
"Language: es",
}
def run(self, channel_name: str, youtube_url: str) -> Generator[Path, None, None]:
for cc_filepath in self.download_closed_captions_svc.run(youtube_url):
storer_svc = SentenceStorerSvc(
db=MarkovDatabase(channel=channel_name, settings=self.s)
)
for line in self.clean_cc_svc.run(cc_filepath):
if line and line not in self.ignore_lines:
storer_svc.store_sentence(line.strip())
yield cc_filepath

View file

@ -1,9 +1,9 @@
from pydantic import BaseModel from pydantic import BaseModel
from src.huesoporro.infra.authenticator import TwitchAuthenticator from huesoporro.infra.authenticator import TwitchAuthenticator
from src.huesoporro.infra.repos import UserRepo from huesoporro.infra.repos import UserRepo
from src.huesoporro.models import User from huesoporro.models import User
from src.huesoporro.settings import Settings from huesoporro.settings import Settings
class RefreshAction(BaseModel): class RefreshAction(BaseModel):

View file

@ -2,9 +2,9 @@ import datetime
from pydantic import BaseModel from pydantic import BaseModel
from src.huesoporro.models import Quote, User from huesoporro.models import Quote, User
from src.huesoporro.svc.is_mod import IsModSvc from huesoporro.svc.is_mod import IsModSvc
from src.huesoporro.svc.quote_storer_svc import QuoteStorerSvc from huesoporro.svc.quote_storer_svc import QuoteStorerSvc
class StoreQuoteAction(BaseModel): class StoreQuoteAction(BaseModel):

View file

@ -2,25 +2,26 @@ import asyncio
import random import random
from collections.abc import Callable from collections.abc import Callable
from enum import StrEnum from enum import StrEnum
from typing import ClassVar
from loguru import logger from loguru import logger
from twitchio import Channel from twitchio import Channel
from twitchio.ext import commands, routines from twitchio.ext import commands, routines
from src.huesoporro.actions.get_random_quote import GetRandomQuoteAction from huesoporro.actions.get_random_quote import GetRandomQuoteAction
from src.huesoporro.actions.store_quote import StoreQuoteAction from huesoporro.actions.store_quote import StoreQuoteAction
from src.huesoporro.api.dependencies import get_settings from huesoporro.infra.db import Database
from src.huesoporro.infra.db import Database from huesoporro.infra.repos import QuoteRepo
from src.huesoporro.infra.repos import QuoteRepo from huesoporro.libs.db import MarkovDatabase
from src.huesoporro.libs.db import Database as MarkovDB from huesoporro.models import ChatbotSettings, User
from src.huesoporro.models import ChatbotSettings, User from huesoporro.settings import Settings
from src.huesoporro.svc.backoff_service import BackoffService from huesoporro.svc.backoff_service import BackoffService
from src.huesoporro.svc.generate import SentenceGeneratorSvc from huesoporro.svc.generate import SentenceGeneratorSvc
from src.huesoporro.svc.get_random_quote import RandomQuoteGetterSvc from huesoporro.svc.get_random_quote import RandomQuoteGetterSvc
from src.huesoporro.svc.hello import HelloGeneratorSvc from huesoporro.svc.hello import get_hello_generator_svc
from src.huesoporro.svc.is_mod import IsModSvc from huesoporro.svc.is_mod import IsModSvc
from src.huesoporro.svc.quote_storer_svc import QuoteStorerSvc from huesoporro.svc.quote_storer_svc import QuoteStorerSvc
from src.huesoporro.svc.store import SentenceStorerSvc from huesoporro.svc.store import SentenceStorerSvc
class Bot(commands.Bot): class Bot(commands.Bot):
@ -30,10 +31,10 @@ class Bot(commands.Bot):
) )
self.channel = channel self.channel = channel
self.user = user self.user = user
self.generate_svc = SentenceGeneratorSvc(db=MarkovDB(channel=channel)) self.generate_svc = SentenceGeneratorSvc(db=MarkovDatabase(channel=channel))
self.hello_svc = HelloGeneratorSvc() self.hello_svc = get_hello_generator_svc()
db = Database() db = Database()
self.quote_repo = QuoteRepo(s=get_settings()) self.quote_repo = QuoteRepo(s=Settings.get())
self.get_random_quote_svc = RandomQuoteGetterSvc(quote_repo=self.quote_repo) self.get_random_quote_svc = RandomQuoteGetterSvc(quote_repo=self.quote_repo)
self.get_random_quote_action = GetRandomQuoteAction( self.get_random_quote_action = GetRandomQuoteAction(
quote_getter_svc=self.get_random_quote_svc quote_getter_svc=self.get_random_quote_svc
@ -54,11 +55,6 @@ class Bot(commands.Bot):
logger.info(f"Logged in as {self.nick}") logger.info(f"Logged in as {self.nick}")
logger.info(f"User id is {self.user_id}") logger.info(f"User id is {self.user_id}")
@commands.command(aliases=["h"])
async def hello(self, ctx: commands.Context, username: str | None = None):
username = username or ctx.author.name
await ctx.send(self.hello_svc.run(username))
@commands.command(aliases=["g"]) @commands.command(aliases=["g"])
async def generate(self, ctx: commands.Context, *, words: str | None = None): async def generate(self, ctx: commands.Context, *, words: str | None = None):
sentence = await self.generate_svc.run(words) sentence = await self.generate_svc.run(words)
@ -98,8 +94,9 @@ class Bot(commands.Bot):
quote = await self.get_random_quote_action.run(channel_name=self.channel) quote = await self.get_random_quote_action.run(channel_name=self.channel)
if quote: if quote:
channel = self.get_channel_conn() channel = self.get_channel_conn()
logger.info(f"Sending random quote {quote.quote}") if channel:
await channel.send(quote.quote) logger.info(f"Sending random quote {quote.quote}")
await channel.send(quote.quote)
async def send_generation(self): async def send_generation(self):
sentence = await self.generate_svc.run() sentence = await self.generate_svc.run()
@ -123,6 +120,23 @@ class Bot(commands.Bot):
self.generation_routine.cancel() self.generation_routine.cancel()
class HelloMessagesCog(commands.Cog):
hello_patterns: ClassVar[list[str]] = ["hola", "HOLA", "hiii", "ayo"]
def __init__(self, bot):
self.bot = bot
self.hello_svc = get_hello_generator_svc()
@commands.Cog.event()
async def event_message(self, message):
if not message.author:
return
if message.content in self.hello_patterns:
hello = self.hello_svc.run(message.author.name)
if hello:
await message.channel.send(hello)
class MessageType(StrEnum): class MessageType(StrEnum):
COMMAND = "COMMAND" COMMAND = "COMMAND"
HELLO = "HELLO" HELLO = "HELLO"
@ -136,7 +150,6 @@ class MessageHandler:
"""Handles different types of messages with their corresponding responses""" """Handles different types of messages with their corresponding responses"""
def __init__(self, channel_send_func: Callable): def __init__(self, channel_send_func: Callable):
self.hello_patterns = ["hola", "HOLA", "hiii", "ayo"]
self.laugh_patterns = [ self.laugh_patterns = [
"om", "om",
"KEK", "KEK",
@ -154,8 +167,6 @@ class MessageHandler:
"""Determines the type of message based on its content""" """Determines the type of message based on its content"""
if content.startswith("!"): if content.startswith("!"):
return MessageType.COMMAND return MessageType.COMMAND
if content in self.hello_patterns:
return MessageType.HELLO
if content == "Yes": if content == "Yes":
return MessageType.YES return MessageType.YES
if content.startswith("WHAT"): if content.startswith("WHAT"):
@ -164,10 +175,6 @@ class MessageHandler:
return MessageType.LAUGH return MessageType.LAUGH
return MessageType.OTHER return MessageType.OTHER
async def handle_hello(self, author_name: str, hello_svc) -> str:
"""Handles hello messages"""
return hello_svc.run(author_name)
async def handle_laugh(self) -> str: async def handle_laugh(self) -> str:
"""Handles laugh messages""" """Handles laugh messages"""
return random.choice(self.laugh_patterns) # noqa: S311 return random.choice(self.laugh_patterns) # noqa: S311
@ -176,20 +183,17 @@ class MessageHandler:
class SaveMessagesCog(commands.Cog): class SaveMessagesCog(commands.Cog):
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
self.store_svc = SentenceStorerSvc(db=MarkovDB(channel=bot.channel)) self.store_svc = SentenceStorerSvc(db=MarkovDatabase(channel=bot.channel))
self.hello_svc = HelloGeneratorSvc() self.generate_svc = SentenceGeneratorSvc(db=MarkovDatabase(channel=bot.channel))
self.backoff_svc = BackoffService() self.backoff_svc = BackoffService()
self.message_handler = MessageHandler(self._send_message) self.message_handler = MessageHandler(self._send_message)
# Register a separate send function for each message type
self.send_functions = { self.send_functions = {
MessageType.HELLO: self._create_typed_send("hello"),
MessageType.YES: self._create_typed_send("yes"), MessageType.YES: self._create_typed_send("yes"),
MessageType.WHAT: self._create_typed_send("what"), MessageType.WHAT: self._create_typed_send("what"),
MessageType.LAUGH: self._create_typed_send("laugh"), MessageType.LAUGH: self._create_typed_send("laugh"),
} }
# Register each send function with its own backoff
for func in self.send_functions.values(): for func in self.send_functions.values():
self.backoff_svc.add_callable(func, backoff_seconds=10) self.backoff_svc.add_callable(func, backoff_seconds=10)
@ -229,10 +233,6 @@ class SaveMessagesCog(commands.Cog):
match msg_type: match msg_type:
case MessageType.COMMAND: case MessageType.COMMAND:
return return
case MessageType.HELLO:
response = await self.message_handler.handle_hello(
message.author.name, self.hello_svc
)
case MessageType.YES: case MessageType.YES:
response = "Indeed" response = "Indeed"
case MessageType.WHAT: case MessageType.WHAT:
@ -258,6 +258,7 @@ class BotsManager:
logger.info(f"Adding bot for {user.user}") logger.info(f"Adding bot for {user.user}")
bot = Bot(user=user, channel=channel, chatbot_settings=chatbot_settings) bot = Bot(user=user, channel=channel, chatbot_settings=chatbot_settings)
bot.add_cog(SaveMessagesCog(bot)) bot.add_cog(SaveMessagesCog(bot))
bot.add_cog(HelloMessagesCog(bot))
self.bots[user.user] = bot self.bots[user.user] = bot
async def run_user_bot(self, user: User): async def run_user_bot(self, user: User):

View file

@ -2,8 +2,8 @@ import httpx
from litestar.exceptions import HTTPException from litestar.exceptions import HTTPException
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from src.huesoporro.models import TwitchAuth from huesoporro.models import TwitchAuth
from src.huesoporro.settings import Settings from huesoporro.settings import Settings
class TwitchAuthenticator(BaseModel): class TwitchAuthenticator(BaseModel):

View file

@ -5,8 +5,8 @@ import aiosqlite
from loguru import logger from loguru import logger
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from src.huesoporro.models import ChatbotSettings, Sentence, User from huesoporro.models import ChatbotSettings, User
from src.huesoporro.settings import Settings from huesoporro.settings import Settings
class Database(BaseModel): class Database(BaseModel):
@ -78,22 +78,3 @@ class Database(BaseModel):
if not result: if not result:
return None return None
return ChatbotSettings(**dict(result)) return ChatbotSettings(**dict(result))
async def save_sentence(self, sentence: str, auto_commit=True):
async with self.get_client(auto_commit=auto_commit) as db:
await db.execute(
"INSERT INTO sentences (sentence) VALUES (?)",
(sentence,),
)
await db.commit()
async def get_sentences(self, user: User) -> list[Sentence]:
async with self.get_client() as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM sentences WHERE user_id = ?", (user.user,)
) as cursor:
result = await cursor.fetchall()
if not result:
return []
return [Sentence(user=user, **dict(value)) for value in result]

View file

@ -6,7 +6,7 @@ from gtts import gTTS
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from src.huesoporro.settings import Settings from huesoporro.settings import Settings
class GTTS(BaseModel): class GTTS(BaseModel):

View file

@ -6,8 +6,8 @@ from typing import Generic, TypeVar
import aiosqlite import aiosqlite
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from src.huesoporro.models import Quote, User from huesoporro.models import Quote, User
from src.huesoporro.settings import Settings from huesoporro.settings import Settings
T = TypeVar("T", bound=BaseModel) T = TypeVar("T", bound=BaseModel)

View file

@ -4,11 +4,12 @@ import sqlite3
import string import string
from typing import Any from typing import Any
import platformdirs
from loguru import logger from loguru import logger
from huesoporro.settings import Settings
class Database:
class MarkovDatabase:
""" """
The database created is called `MarkovChain_{channel}.db`, The database created is called `MarkovChain_{channel}.db`,
and populated with 27 + 27^2 = 756 tables. Firstly, 27 tables with the structure of and populated with 27 + 27^2 = 756 tables. Firstly, 27 tables with the structure of
@ -86,14 +87,11 @@ class Database:
to both get results from "hello" and "hello,". to both get results from "hello" and "hello,".
""" """
def __init__(self, channel: str): def __init__(self, channel: str, settings: Settings | None = None):
self.user_data_path = platformdirs.user_data_path( settings = settings or Settings.get()
"huesoporro",
ensure_exists=True, self.db_path = settings.default_data_path / f"MarkovChain_{channel}.db"
) self.user_data_path = self.db_path.parent
self.db_path = (
self.user_data_path / f"MarkovChain_{channel.replace('#', '').lower()}.db"
)
self._execute_queue: list = [] self._execute_queue: list = []
if self.db_path.is_file(): if self.db_path.is_file():
@ -357,7 +355,7 @@ class Database:
from nltk import ngrams from nltk import ngrams
from src.huesoporro.libs.tokenizer import tokenize from huesoporro.libs.tokenizer import tokenize
channel = channel.replace("#", "").lower() channel = channel.replace("#", "").lower()
copyfile( copyfile(

View file

@ -1,6 +1,6 @@
import uvicorn import uvicorn
from src.huesoporro.settings import Settings from huesoporro.settings import Settings
if __name__ == "__main__": if __name__ == "__main__":
settings = Settings.get() settings = Settings.get()

View file

@ -4,7 +4,7 @@ from typing import Literal
import jwt import jwt
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from src.huesoporro.settings import Settings from huesoporro.settings import Settings
class TwitchAuth(BaseModel): class TwitchAuth(BaseModel):

View file

@ -1,6 +1,7 @@
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
import platformdirs
from pydantic import Field, HttpUrl, SecretStr, field_validator from pydantic import Field, HttpUrl, SecretStr, field_validator
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@ -8,18 +9,19 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
port: int = 8000 port: int = 8000
host: str = "0.0.0.0" # noqa: S104 host: str = "0.0.0.0" # noqa: S104
default_data_path: Path = platformdirs.user_data_path(
"huesoporro",
ensure_exists=True,
)
static_files_path: Path = Field( static_files_path: Path = Field(
default_factory=lambda: Path(__file__).parent / "static" default_factory=lambda: Path(__file__).parent / "static"
) )
templates_files_path: Path = Field( templates_files_path: Path = Field(
default_factory=lambda: Path(__file__).parent / "templates" default_factory=lambda: Path(__file__).parent / "templates"
) )
tts_cache_path: Path = Field( tts_cache_path: Path = default_data_path / "tts_files"
default_factory=lambda: Path(__file__).parent / "tts_files" db_filepath: Path = default_data_path / "huesoporro.db"
)
db_filepath: Path = Field(
default_factory=lambda: Path(__file__).parent / "huesoporro.db"
)
twitch_client_id: str twitch_client_id: str
twitch_client_secret: SecretStr twitch_client_secret: SecretStr
jwt_secret: SecretStr jwt_secret: SecretStr
@ -31,8 +33,8 @@ class Settings(BaseSettings):
@staticmethod @staticmethod
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get(): def get(**data):
return Settings() # type: ignore[call-arg] # pydantic-setting magic return Settings(**data) # type: ignore[call-arg] # pydantic-setting magic
@field_validator("allowed_users") @field_validator("allowed_users")
@classmethod @classmethod

View file

@ -0,0 +1,57 @@
import re
from collections.abc import Generator
from pathlib import Path
from pydantic import BaseModel
class CleanCCSvc(BaseModel):
@classmethod
def clean_vtt_line(cls, line):
"""
Clean a single line of VTT text by removing timestamps and HTML tags.
Args:
line (str): Single line from VTT file
Returns:
str: Cleaned line or None if line should be skipped
"""
# Skip empty lines
if not line.strip():
return None
# Skip WEBVTT header
if line.strip().startswith("WEBVTT"):
return None
# Skip timestamp lines (e.g., "00:00:00.000 --> 00:00:02.000")
if re.match(
r"\d{2}:\d{2}:\d{2}\.\d{3}\s+-{2}>\s+\d{2}:\d{2}:\d{2}\.\d{3}", line
):
return None
# Skip numeric identifiers
if line.strip().isdigit():
return None
# Remove HTML-style tags
line = re.sub(r"<[^>]+>", "", line)
# Remove multiple spaces
line = " ".join(line.split())
return line.strip()
@classmethod
def process_vtt_file(cls, file_path: Path):
seen_lines = set()
with file_path.open("r", encoding="utf-8") as f:
for line in f:
cleaned = cls.clean_vtt_line(line)
if line not in seen_lines:
seen_lines.add(line)
yield cleaned
def run(self, cc_file_path: Path) -> Generator[str, None, None]:
return self.process_vtt_file(cc_file_path)

View file

@ -0,0 +1,44 @@
import tempfile
from collections.abc import Generator
from pathlib import Path
import yt_dlp
from loguru import logger
from pydantic import BaseModel
class DownloadClosedCaptionsSvc(BaseModel):
@staticmethod
def run(youtube_url: str, sub_lang: str = "es") -> Generator[Path, None, None]:
"""Download closed captions from a yt video and save it to a temp file
Args:
youtube_url: URL of the YouTube video
sub_lang: Language code for the subtitles (default: "es" for Spanish)
Returns:
Path: Path to the downloaded subtitles file
Raises:
ValueError: If subtitles are not available in the requested language
"""
temp_dir = Path(tempfile.mkdtemp())
logger.info(f"Downloading subtitles for {youtube_url} to {temp_dir}")
ydl_opts = {
"skip_download": True,
"writesubtitles": True,
"writeautomaticsub": True,
"subtitleslangs": [sub_lang],
"subtitlesformat": "vtt",
"cookiesfrombrowser": ("firefox",),
"paths": {"home": str(temp_dir)},
"quiet": True,
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([youtube_url])
return temp_dir.glob(f"*.{sub_lang}.vtt")
except yt_dlp.utils.DownloadError as exc:
raise ValueError(f"Failed to download subtitles: {exc!s}") from exc

View file

@ -3,8 +3,8 @@ import string
from loguru import logger from loguru import logger
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from src.huesoporro.libs.db import Database as MarkovDB from huesoporro.libs.db import MarkovDatabase as MarkovDB
from src.huesoporro.libs.tokenizer import detokenize, tokenize from huesoporro.libs.tokenizer import detokenize, tokenize
class SentenceGeneratorSvc(BaseModel): class SentenceGeneratorSvc(BaseModel):

View file

@ -1,7 +1,7 @@
from pydantic import BaseModel from pydantic import BaseModel
from src.huesoporro.infra.db import Database from huesoporro.infra.db import Database
from src.huesoporro.models import ChatbotSettings, User from huesoporro.models import ChatbotSettings, User
class ChatbotSettingsGetterSvc(BaseModel): class ChatbotSettingsGetterSvc(BaseModel):

View file

@ -1,7 +1,7 @@
from pydantic import BaseModel from pydantic import BaseModel
from src.huesoporro.infra.repos import QuoteRepo from huesoporro.infra.repos import QuoteRepo
from src.huesoporro.models import Quote from huesoporro.models import Quote
class RandomQuoteGetterSvc(BaseModel): class RandomQuoteGetterSvc(BaseModel):

View file

@ -1,11 +0,0 @@
from pydantic import BaseModel
from src.huesoporro.infra.db import Database
from src.huesoporro.models import Sentence, User
class SentencesGetterSvc(BaseModel):
db: Database
async def run(self, user: User) -> list[Sentence]:
return await self.db.get_sentences(user=user)

View file

@ -1,20 +1,35 @@
import random import random
from functools import lru_cache
from loguru import logger
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class HelloGeneratorSvc(BaseModel): class HelloGeneratorSvc(BaseModel):
hellos: list[str] = Field( hellos: list[str] = [
default_factory=lambda: [ "Hola",
"Hola", "Ayo",
"Ayo", "Hi",
"Hi", "Bon día",
"Bon día", "Hola mi tremendo elemento",
"Hola mi tremendo elemento", "HOLA",
"HOLA", "hiii",
"hiii", ]
]
)
def run(self, username: str): greeted_users: dict[str, str] = Field(default_factory=dict)
return f"{random.choice(self.hellos)} @{username}" # noqa: S311
def run(self, username: str) -> str | None:
if username in self.greeted_users:
logger.info(f"User {username} already greeted")
return None
greeting = f"{random.choice(self.hellos)} @{username}" # noqa: S311
self.greeted_users[username] = greeting
return greeting
@lru_cache(maxsize=1)
def get_hello_generator_svc() -> HelloGeneratorSvc:
return HelloGeneratorSvc()

View file

@ -1,7 +1,7 @@
from pydantic import BaseModel from pydantic import BaseModel
from src.huesoporro.infra.db import Database from huesoporro.infra.db import Database
from src.huesoporro.models import User from huesoporro.models import User
class IsModSvc(BaseModel): class IsModSvc(BaseModel):

View file

@ -1,7 +1,7 @@
from pydantic import BaseModel from pydantic import BaseModel
from src.huesoporro.infra.repos import QuoteRepo from huesoporro.infra.repos import QuoteRepo
from src.huesoporro.models import Quote from huesoporro.models import Quote
class QuoteStorerSvc(BaseModel): class QuoteStorerSvc(BaseModel):

View file

@ -2,12 +2,12 @@ from loguru import logger
from nltk.tokenize import sent_tokenize from nltk.tokenize import sent_tokenize
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from src.huesoporro.libs.db import Database as MarkovDB from huesoporro.libs.db import MarkovDatabase
from src.huesoporro.libs.tokenizer import tokenize from huesoporro.libs.tokenizer import tokenize
class SentenceStorerSvc(BaseModel): class SentenceStorerSvc(BaseModel):
db: MarkovDB db: MarkovDatabase
key_length: int = 2 key_length: int = 2
end_tag: str = "<END>" end_tag: str = "<END>"

View file

@ -1,7 +1,7 @@
from pydantic import BaseModel from pydantic import BaseModel
from src.huesoporro.infra.db import Database from huesoporro.infra.db import Database
from src.huesoporro.models import ChatbotSettings, User from huesoporro.models import ChatbotSettings, User
class ChatbotSettingsStorerSvc(BaseModel): class ChatbotSettingsStorerSvc(BaseModel):

View file

@ -185,7 +185,6 @@
const chatbotManager = new ChatbotManager(); const chatbotManager = new ChatbotManager();
chatbotManager.setEvents(); chatbotManager.setEvents();
}); });
</script> </script>
</body> </body>

View file

@ -2,9 +2,8 @@
<details class="dropdown"> <details class="dropdown">
<summary>Le Funny</summary> <summary>Le Funny</summary>
<ul dir="rtl"> <ul dir="rtl">
<li><a href="/sentences" >Sentences</a></li> <li><a href="/quotes" disabled>Quotes</a></li>
<li><a href="#" disabled>Quotes</a></li> <li><a href="/copypastas" disabled>Copypastas</a></li>
<li><a href="#" disabled>Copypastas</a></li>
</ul> </ul>
</details> </details>
</li> </li>

View file

@ -1,137 +0,0 @@
{% include 'header.html' %}
<body>
<header>
<nav class="container">
<ul>
<li><a href="/">Chatbot</a></li>
<li><a href="/tts">TTS</a></li>
{% include 'le_funny_dropdown.html' %}
</ul>
{% include 'logout.html' %}
</nav>
<script>
function timestampToAwareDatetime(timestamp) {
const awareDatetime = new Date(timestamp);
awareDatetime.setMinutes(awareDatetime.getMinutes() - awareDatetime.getTimezoneOffset());
return awareDatetime;
}
function prettifyTimestamp(timestamp) {
const awareDatetime = timestampToAwareDatetime(timestamp);
return awareDatetime.toLocaleString();
}
function saveNewSentence(shortHash) {
/*
Save a newly-added sentence by POST'ing against /api/v1/sentences.
- if response is not 200, return nothing (validation logic to be added later
- if response is 200, retrieve save-new-${shortHash} and delete-new-${shortHas} and change their ids
to save-${newSentenceIdRetrievedFromResponse} and delete-${newSentenceIdRetrievedFromResponse}
assume a body like: {"id": 1, "sentence": "New sentence"}
*/
const sentenceTextInput = document.getElementById(`newSentence${shortHash}`);
const sentence = sentenceTextInput.value;
// if sentence is empty, return
if (!sentence) {
return;
}
const sentenceData = {
"sentence": sentence
}
fetch(`/api/v1/sentences`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(sentenceData)
}).then(response => {
response.json().then(data => {
console.log(data);
const saveButton = document.getElementById(`saveNew${shortHash}`);
const deleteButton = document.getElementById(`deleteNew${shortHash}`);
saveButton.id = `save-${data.id}`;
deleteButton.id = `delete-${data.id}`;
});
});
}
function saveExistingSentence(id) {
// get sentence value by
}
document.addEventListener('DOMContentLoaded', function () {
const timestampCells = document.querySelectorAll('.timestamp-cell');
console.log(timestampCells);
timestampCells.forEach(cell => {
const timestampStr = cell.textContent.trim();
const timestamp = parseFloat(timestampStr) * 1000;
cell.textContent = prettifyTimestamp(timestamp);
});
const addButton = document.getElementById('add-sentence');
addButton.addEventListener('click', () => {
// add a new empty row at the top of the tbody
const sentencesTable = document.getElementById('sentencesTable');
const newRow = sentencesTable.insertRow();
const nowPrettyDate = prettifyTimestamp(Date.now());
const shortHash = Math.random().toString(36).substring(2, 10);
newRow.innerHTML = `
<td><textarea placeholder="Enter new sentence" id="newSentence${shortHash}"></textarea></td>
<td class="timestamp-cell">${nowPrettyDate}</td>
<td>
<div class="container">
<button id="saveNew${shortHash}" style="background-color: #00c482; border-color: #00c482" onclick="saveNewSentence('${shortHash}')">
Save
</button>
<button id="deleteNew${shortHash}" style="border-color: #aa0000; background-color: #aa0000" onclick="this.parentElement.parentElement.parentElement.remove()">
Delete
</button>
</div>
</td>
`;
});
});
</script>
</header>
<main class="container">
<section>
<form role="search">
<input name="search" type="search" placeholder="Search sentence"/>
<input type="submit" value="Search"/>
<button id="add-sentence" class="secondary" type="button">Add</button>
</form>
<table id="sentencesTable">
<thead style="background-color: white; color: black">
<tr>
<th scope="col">Sentence</th>
<th scope="col">Last modified</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody id="sentencesTableBody">
{% for sentence in sentences %}
<tr>
<td><textarea>{{ sentence.sentence }}</textarea></td>
<td class="timestamp-cell">{{ sentence.last_updated_at }}</td>
<td>
<div class="container">
<button id="save-{{ sentence.id }}" style="background-color: #00c482; border-color: #00c482">
Save
</button>
<button id="delete-{{ sentence.id }}" style="border-color: #aa0000; background-color: #aa0000">
Delete
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
</body>
</html>

View file

@ -7,7 +7,7 @@ from gtts import gTTS
from litestar import WebSocket from litestar import WebSocket
from loguru import logger from loguru import logger
from src.huesoporro.settings import Settings from huesoporro.settings import Settings
class TTSManager: class TTSManager:

View file

@ -6,11 +6,11 @@ from caribou.migrate import load_migrations
from polyfactory.factories.pydantic_factory import ModelFactory from polyfactory.factories.pydantic_factory import ModelFactory
from polyfactory.pytest_plugin import register_fixture from polyfactory.pytest_plugin import register_fixture
from src.huesoporro.infra.db import Database from huesoporro.infra.db import Database
from src.huesoporro.models import ChatbotSettings, Quote, User from huesoporro.models import ChatbotSettings, Quote, User
from src.huesoporro.settings import Settings from huesoporro.settings import Settings
from src.huesoporro.svc.backoff_service import BackoffService from huesoporro.svc.backoff_service import BackoffService
from src.huesoporro.svc.is_mod import IsModSvc from huesoporro.svc.is_mod import IsModSvc
@pytest.fixture @pytest.fixture

View file

@ -2,8 +2,8 @@ import json
import pytest import pytest
from src.huesoporro.infra.repos import QuoteRepo, UserRepo from huesoporro.infra.repos import QuoteRepo, UserRepo
from src.huesoporro.models import User from huesoporro.models import User
@pytest.fixture @pytest.fixture

View file

@ -3,8 +3,8 @@ import time
import pytest import pytest
from src.huesoporro.models import ChatbotSettings, User from huesoporro.models import ChatbotSettings, User
from src.huesoporro.svc.is_mod import IsModSvc from huesoporro.svc.is_mod import IsModSvc
async def test_is_mod_svc_returns_true_for_channel(is_mod_svc: IsModSvc, user: User): async def test_is_mod_svc_returns_true_for_channel(is_mod_svc: IsModSvc, user: User):

45
uv.lock generated
View file

@ -517,8 +517,8 @@ wheels = [
[[package]] [[package]]
name = "huesoporro" name = "huesoporro"
version = "0.2.9" version = "0.3.0"
source = { virtual = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },
{ name = "caribou" }, { name = "caribou" },
@ -538,6 +538,10 @@ dependencies = [
] ]
[package.dev-dependencies] [package.dev-dependencies]
cli = [
{ name = "typer" },
{ name = "yt-dlp" },
]
dev = [ dev = [
{ name = "mypy" }, { name = "mypy" },
{ name = "polyfactory" }, { name = "polyfactory" },
@ -567,6 +571,10 @@ requires-dist = [
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
cli = [
{ name = "typer", specifier = ">=0.15.1" },
{ name = "yt-dlp", specifier = ">=2025.1.26" },
]
dev = [ dev = [
{ name = "mypy", specifier = ">=1.13.0" }, { name = "mypy", specifier = ">=1.13.0" },
{ name = "polyfactory", specifier = ">=2.18.1" }, { name = "polyfactory", specifier = ">=2.18.1" },
@ -1367,6 +1375,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/6a/aca01554949f3a401991dc32fe22837baeaccb8a0d868256cbb26a029778/ruff-0.9.7-py3-none-win_arm64.whl", hash = "sha256:b075a700b2533feb7a01130ff656a4ec0d5f340bb540ad98759b8401c32c2037", size = 10177763 }, { url = "https://files.pythonhosted.org/packages/63/6a/aca01554949f3a401991dc32fe22837baeaccb8a0d868256cbb26a029778/ruff-0.9.7-py3-none-win_arm64.whl", hash = "sha256:b075a700b2533feb7a01130ff656a4ec0d5f340bb540ad98759b8401c32c2037", size = 10177763 },
] ]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
]
[[package]] [[package]]
name = "six" name = "six"
version = "1.17.0" version = "1.17.0"
@ -1450,6 +1467,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/be/49d93b0e13dad69a636e550a7b96a5208af9a91100f9b142a363882e0c4c/twitchio-2.10.0-py3-none-any.whl", hash = "sha256:7aa0b6950dad90feeb04b03fd10d3e4292fa8a7c2e7aea6b2fd6686bc5425fb2", size = 143761 }, { url = "https://files.pythonhosted.org/packages/4b/be/49d93b0e13dad69a636e550a7b96a5208af9a91100f9b142a363882e0c4c/twitchio-2.10.0-py3-none-any.whl", hash = "sha256:7aa0b6950dad90feeb04b03fd10d3e4292fa8a7c2e7aea6b2fd6686bc5425fb2", size = 143761 },
] ]
[[package]]
name = "typer"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908 },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.12.2" version = "4.12.2"
@ -1688,3 +1720,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/d5/688db678e987c3e0fb17867970700b92603cadf36c56e5fb08f23e822a0c/yarl-1.18.3-cp313-cp313-win_amd64.whl", hash = "sha256:578e281c393af575879990861823ef19d66e2b1d0098414855dd367e234f5b3c", size = 315723 }, { url = "https://files.pythonhosted.org/packages/f5/d5/688db678e987c3e0fb17867970700b92603cadf36c56e5fb08f23e822a0c/yarl-1.18.3-cp313-cp313-win_amd64.whl", hash = "sha256:578e281c393af575879990861823ef19d66e2b1d0098414855dd367e234f5b3c", size = 315723 },
{ url = "https://files.pythonhosted.org/packages/f5/4b/a06e0ec3d155924f77835ed2d167ebd3b211a7b0853da1cf8d8414d784ef/yarl-1.18.3-py3-none-any.whl", hash = "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b", size = 45109 }, { url = "https://files.pythonhosted.org/packages/f5/4b/a06e0ec3d155924f77835ed2d167ebd3b211a7b0853da1cf8d8414d784ef/yarl-1.18.3-py3-none-any.whl", hash = "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b", size = 45109 },
] ]
[[package]]
name = "yt-dlp"
version = "2025.2.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/36/ef300ba4a228b74612d4013b43ed303a0d6d2de17a71fc37e0b821577e0a/yt_dlp-2025.2.19.tar.gz", hash = "sha256:f33ca76df2e4db31880f2fe408d44f5058d9f135015b13e50610dfbe78245bea", size = 2929199 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/45/6d1b759e68f5363b919828fb0e0c167a1cd5003b5b7c74cc0f0c2096be4f/yt_dlp-2025.2.19-py3-none-any.whl", hash = "sha256:3ed218eaeece55e9d715afd41abc450dc406ee63bf79355169dfde312d38fdb8", size = 3186543 },
]