From b2185f4174af6dc2085c92a23aaf76ab6cbba63e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?c=C4=83t=C4=83lin?= Date: Wed, 26 Feb 2025 11:53:18 +0100 Subject: [PATCH] feat: remove !h and make the bot have an in-memory dict of greeted users instead of using the backoff service --- Dockerfile | 3 +- Makefile | 2 +- charts/huesoporro/Chart.yaml | 4 +- charts/huesoporro/values.yaml | 2 +- devenv.nix | 1 - pyproject.toml | 20 ++- src/{ => apps}/__init__.py | 0 src/{huesoporro/api => apps/cli}/__init__.py | 0 .../api/routes => apps/cli/typer}/__init__.py | 0 src/apps/cli/typer/main.py | 24 +++ src/apps/httpapi/__init__.py | 0 src/apps/httpapi/litestar/__init__.py | 0 .../httpapi/litestar}/dependencies.py | 25 ++-- .../api => apps/httpapi/litestar}/errors.py | 0 .../api => apps/httpapi/litestar}/main.py | 20 +-- src/apps/httpapi/litestar/routes/__init__.py | 0 .../httpapi/litestar}/routes/api.py | 35 ++--- .../httpapi/litestar}/routes/auth.py | 4 +- src/huesoporro/actions/authenticate.py | 8 +- src/huesoporro/actions/get_random_quote.py | 4 +- src/huesoporro/actions/get_user_by_jwt.py | 8 +- src/huesoporro/actions/import_from_vod.py | 34 +++++ src/huesoporro/actions/refresh.py | 8 +- src/huesoporro/actions/store_quote.py | 6 +- src/huesoporro/bot.py | 81 ++++++----- src/huesoporro/infra/authenticator.py | 4 +- src/huesoporro/infra/db.py | 23 +-- src/huesoporro/infra/gtts.py | 2 +- src/huesoporro/infra/repos.py | 4 +- src/huesoporro/libs/db.py | 20 ++- src/huesoporro/main.py | 2 +- src/huesoporro/models.py | 2 +- src/huesoporro/settings.py | 18 ++- src/huesoporro/svc/clean_cc_svc.py | 57 ++++++++ .../svc/download_closed_captions.py | 44 ++++++ src/huesoporro/svc/generate.py | 4 +- src/huesoporro/svc/get_chatbot_settings.py | 4 +- src/huesoporro/svc/get_random_quote.py | 4 +- src/huesoporro/svc/get_sentences_svc.py | 11 -- src/huesoporro/svc/hello.py | 41 ++++-- src/huesoporro/svc/is_mod.py | 4 +- src/huesoporro/svc/quote_storer_svc.py | 4 +- src/huesoporro/svc/store.py | 6 +- src/huesoporro/svc/store_settings.py | 4 +- src/huesoporro/templates/index.html | 1 - .../templates/le_funny_dropdown.html | 5 +- src/huesoporro/templates/sentences.html | 137 ------------------ src/huesoporro/tts.py | 2 +- tests/conftest.py | 10 +- tests/test_repos.py | 4 +- tests/test_svc.py | 4 +- uv.lock | 45 +++++- 52 files changed, 403 insertions(+), 352 deletions(-) rename src/{ => apps}/__init__.py (100%) rename src/{huesoporro/api => apps/cli}/__init__.py (100%) rename src/{huesoporro/api/routes => apps/cli/typer}/__init__.py (100%) create mode 100644 src/apps/cli/typer/main.py create mode 100644 src/apps/httpapi/__init__.py create mode 100644 src/apps/httpapi/litestar/__init__.py rename src/{huesoporro/api => apps/httpapi/litestar}/dependencies.py (66%) rename src/{huesoporro/api => apps/httpapi/litestar}/errors.py (100%) rename src/{huesoporro/api => apps/httpapi/litestar}/main.py (84%) create mode 100644 src/apps/httpapi/litestar/routes/__init__.py rename src/{huesoporro/api => apps/httpapi/litestar}/routes/api.py (75%) rename src/{huesoporro/api => apps/httpapi/litestar}/routes/auth.py (89%) create mode 100644 src/huesoporro/actions/import_from_vod.py create mode 100644 src/huesoporro/svc/clean_cc_svc.py create mode 100644 src/huesoporro/svc/download_closed_captions.py delete mode 100644 src/huesoporro/svc/get_sentences_svc.py delete mode 100644 src/huesoporro/templates/sentences.html diff --git a/Dockerfile b/Dockerfile index 226e72a..f58efb1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,9 +29,10 @@ WORKDIR "$APP_PATH" COPY --chown=$USERNAME pyproject.toml uv.lock Makefile README.md ./ +COPY --chown=$USERNAME src/ src/ + RUN uv sync -COPY --chown=$USERNAME src/ src/ COPY --chown=$USERNAME migrations/ migrations/ diff --git a/Makefile b/Makefile index 1443b85..61f0995 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ tests: uv run coverage xml serve: - uv run python -m src.huesoporro.main + uv run python -m src.apps.httpapi.litestar.main build: docker build . -t git.roboces.dev/catalin/$(PROJECT_NAME):$(PROJECT_TAG) --target $(PROJECT_TARGET) diff --git a/charts/huesoporro/Chart.yaml b/charts/huesoporro/Chart.yaml index 782ccdb..716c06d 100644 --- a/charts/huesoporro/Chart.yaml +++ b/charts/huesoporro/Chart.yaml @@ -15,10 +15,10 @@ type: application # 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. # 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 # 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. # It is recommended to use it with quotes. -appVersion: "0.2.9" +appVersion: "0.3.0" diff --git a/charts/huesoporro/values.yaml b/charts/huesoporro/values.yaml index 7f92219..54c4d4d 100644 --- a/charts/huesoporro/values.yaml +++ b/charts/huesoporro/values.yaml @@ -11,7 +11,7 @@ image: # This sets the pull policy for images. pullPolicy: Always # 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/ imagePullSecrets: [] diff --git a/devenv.nix b/devenv.nix index 2342729..06edec4 100644 --- a/devenv.nix +++ b/devenv.nix @@ -10,7 +10,6 @@ languages.python.version = "3.12.8"; enterShell = '' - uv sync ''; dotenv.enable = true; diff --git a/pyproject.toml b/pyproject.toml index 53cc9f2..c30ecf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "huesoporro" -version = "0.2.9" -description = "Misc Twitch bots" +version = "0.3.0" +description = "Misc Twitch bot" readme = "README.md" authors = [ { name = "185504a9", email = "catalin@roboces.dev" } @@ -25,6 +25,13 @@ dependencies = [ "discord-py>=2.4.0", ] +[project.scripts] +huesoporro = "apps.cli.typer.main:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [tool.uv] dev-dependencies = [ "mypy>=1.13.0", @@ -46,7 +53,8 @@ module = [ "caribou.migrate", "twitchio", "twitchio.ext", - "gtts" + "gtts", + "yt_dlp" ] ignore_missing_imports = true @@ -60,3 +68,9 @@ extend-ignore = ["S101", "ISC002", "COM812", "ISC001", "EM101", "EM102"] [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" + +[dependency-groups] +cli = [ + "typer>=0.15.1", + "yt-dlp>=2025.1.26", +] diff --git a/src/__init__.py b/src/apps/__init__.py similarity index 100% rename from src/__init__.py rename to src/apps/__init__.py diff --git a/src/huesoporro/api/__init__.py b/src/apps/cli/__init__.py similarity index 100% rename from src/huesoporro/api/__init__.py rename to src/apps/cli/__init__.py diff --git a/src/huesoporro/api/routes/__init__.py b/src/apps/cli/typer/__init__.py similarity index 100% rename from src/huesoporro/api/routes/__init__.py rename to src/apps/cli/typer/__init__.py diff --git a/src/apps/cli/typer/main.py b/src/apps/cli/typer/main.py new file mode 100644 index 0000000..5f14c84 --- /dev/null +++ b/src/apps/cli/typer/main.py @@ -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}") diff --git a/src/apps/httpapi/__init__.py b/src/apps/httpapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/httpapi/litestar/__init__.py b/src/apps/httpapi/litestar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/huesoporro/api/dependencies.py b/src/apps/httpapi/litestar/dependencies.py similarity index 66% rename from src/huesoporro/api/dependencies.py rename to src/apps/httpapi/litestar/dependencies.py index d1d9812..bf80ece 100644 --- a/src/huesoporro/api/dependencies.py +++ b/src/apps/httpapi/litestar/dependencies.py @@ -1,16 +1,17 @@ from litestar import Request from litestar.exceptions import HTTPException -from src.huesoporro.actions.authenticate import AuthenticateAction -from src.huesoporro.actions.get_user_by_jwt import GetUserByJWTAction -from src.huesoporro.infra.authenticator import TwitchAuthenticator -from src.huesoporro.infra.db import Database -from src.huesoporro.infra.repos import UserRepo -from src.huesoporro.models import User -from src.huesoporro.settings import Settings -from src.huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc -from src.huesoporro.svc.get_sentences_svc import SentencesGetterSvc -from src.huesoporro.svc.store_settings import ChatbotSettingsStorerSvc +from huesoporro.actions.authenticate import AuthenticateAction +from huesoporro.actions.get_user_by_jwt import GetUserByJWTAction +from huesoporro.infra.authenticator import TwitchAuthenticator +from huesoporro.infra.db import Database +from huesoporro.infra.repos import UserRepo +from huesoporro.libs.db import MarkovDatabase +from huesoporro.models import User +from huesoporro.settings import Settings +from huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc +from huesoporro.svc.store import SentenceStorerSvc +from huesoporro.svc.store_settings import ChatbotSettingsStorerSvc def get_settings() -> Settings: @@ -53,8 +54,8 @@ async def store_chatbot_settings_svc(db: Database): return ChatbotSettingsStorerSvc(db=db) -async def get_sentences_svc(db: Database): - return SentencesGetterSvc(db=db) +async def get_sentences_storer_svc(db: MarkovDatabase): + return SentenceStorerSvc(db=db) async def get_user_repo(s: Settings): diff --git a/src/huesoporro/api/errors.py b/src/apps/httpapi/litestar/errors.py similarity index 100% rename from src/huesoporro/api/errors.py rename to src/apps/httpapi/litestar/errors.py diff --git a/src/huesoporro/api/main.py b/src/apps/httpapi/litestar/main.py similarity index 84% rename from src/huesoporro/api/main.py rename to src/apps/httpapi/litestar/main.py index 8df704e..dbd0d2b 100644 --- a/src/huesoporro/api/main.py +++ b/src/apps/httpapi/litestar/main.py @@ -6,37 +6,35 @@ from litestar.exceptions import HTTPException from litestar.static_files import StaticFilesConfig from litestar.template import TemplateConfig -from src.huesoporro.api.dependencies import ( +from apps.httpapi.litestar.dependencies import ( authenticate, get_authenticate_action, get_authenticator, get_chatbot_settings_svc, get_db, get_get_user_by_jwt_action, - get_sentences_svc, + get_sentences_storer_svc, get_settings, get_user_repo, store_chatbot_settings_svc, ) -from src.huesoporro.api.errors import ( +from apps.httpapi.litestar.errors import ( after_exception_handler, http_exception_handler, httpx_status_error_handler, ) -from src.huesoporro.api.routes.api import ( +from apps.httpapi.litestar.routes.api import ( get_bot_settings, get_bot_status, get_index, - get_sentences, get_tts_overlay, get_tts_permalink, manage_bot, save_bot_settings, - save_new_sentence, ) -from src.huesoporro.api.routes.auth import get_code, login -from src.huesoporro.bot import BotsManager -from src.huesoporro.settings import Settings +from apps.httpapi.litestar.routes.auth import get_code, login +from huesoporro.bot import BotsManager +from huesoporro.settings import Settings @get("/healthz") @@ -57,8 +55,6 @@ def create_app(): get_bot_status, save_bot_settings, get_bot_settings, - get_sentences, - save_new_sentence, ], static_files_config=( StaticFilesConfig( @@ -87,7 +83,7 @@ def create_app(): "bm": Provide(BotsManager, use_cache=True), "gbs": Provide(get_chatbot_settings_svc), "sbs": Provide(store_chatbot_settings_svc), - "sgs": Provide(get_sentences_svc), + "sss": Provide(get_sentences_storer_svc), "authenticator": Provide(get_authenticator), "authenticate_action": Provide(get_authenticate_action), "user_repo": Provide(get_user_repo), diff --git a/src/apps/httpapi/litestar/routes/__init__.py b/src/apps/httpapi/litestar/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/huesoporro/api/routes/api.py b/src/apps/httpapi/litestar/routes/api.py similarity index 75% rename from src/huesoporro/api/routes/api.py rename to src/apps/httpapi/litestar/routes/api.py index 607789f..9fd317b 100644 --- a/src/huesoporro/api/routes/api.py +++ b/src/apps/httpapi/litestar/routes/api.py @@ -1,14 +1,14 @@ 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 pydantic import BaseModel +from pydantic import BaseModel, ConfigDict -from src.huesoporro.bot import BotsManager -from src.huesoporro.models import ChatbotSettings, User -from src.huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc -from src.huesoporro.svc.get_sentences_svc import SentencesGetterSvc -from src.huesoporro.svc.store_settings import ChatbotSettingsStorerSvc +from huesoporro.bot import BotsManager +from huesoporro.models import ChatbotSettings, User +from huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc +from huesoporro.svc.store_settings import ChatbotSettingsStorerSvc class ManageBotDTO(BaseModel): @@ -16,6 +16,13 @@ class ManageBotDTO(BaseModel): channel_name: str | None = None +class ImportTextFileDTO(BaseModel): + file: UploadFile + channel_name: str + + model_config = ConfigDict(arbitrary_types_allowed=True) + + @get( "/tts", media_type=MediaType.HTML, @@ -97,17 +104,3 @@ async def save_bot_settings( ) -> dict: await sbs.run(user=user, bot_settings=data) 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"]} diff --git a/src/huesoporro/api/routes/auth.py b/src/apps/httpapi/litestar/routes/auth.py similarity index 89% rename from src/huesoporro/api/routes/auth.py rename to src/apps/httpapi/litestar/routes/auth.py index 7d930b2..cd01bdc 100644 --- a/src/huesoporro/api/routes/auth.py +++ b/src/apps/httpapi/litestar/routes/auth.py @@ -4,8 +4,8 @@ from litestar import MediaType, get from litestar.datastructures.cookie import Cookie from litestar.response import Redirect, Template -from src.huesoporro.actions.authenticate import AuthenticateAction -from src.huesoporro.settings import Settings +from huesoporro.actions.authenticate import AuthenticateAction +from huesoporro.settings import Settings @get(path="/o/code") diff --git a/src/huesoporro/actions/authenticate.py b/src/huesoporro/actions/authenticate.py index 9bf7b6a..7fcb466 100644 --- a/src/huesoporro/actions/authenticate.py +++ b/src/huesoporro/actions/authenticate.py @@ -1,9 +1,9 @@ from pydantic import BaseModel -from src.huesoporro.infra.authenticator import TwitchAuthenticator -from src.huesoporro.infra.repos import UserRepo -from src.huesoporro.models import User -from src.huesoporro.settings import Settings +from huesoporro.infra.authenticator import TwitchAuthenticator +from huesoporro.infra.repos import UserRepo +from huesoporro.models import User +from huesoporro.settings import Settings class AuthenticateAction(BaseModel): diff --git a/src/huesoporro/actions/get_random_quote.py b/src/huesoporro/actions/get_random_quote.py index 47c7b5b..99df32b 100644 --- a/src/huesoporro/actions/get_random_quote.py +++ b/src/huesoporro/actions/get_random_quote.py @@ -1,7 +1,7 @@ from pydantic import BaseModel -from src.huesoporro.models import Quote -from src.huesoporro.svc.get_random_quote import RandomQuoteGetterSvc +from huesoporro.models import Quote +from huesoporro.svc.get_random_quote import RandomQuoteGetterSvc class GetRandomQuoteAction(BaseModel): diff --git a/src/huesoporro/actions/get_user_by_jwt.py b/src/huesoporro/actions/get_user_by_jwt.py index 4993b4c..21e362e 100644 --- a/src/huesoporro/actions/get_user_by_jwt.py +++ b/src/huesoporro/actions/get_user_by_jwt.py @@ -1,10 +1,10 @@ from loguru import logger from pydantic import BaseModel -from src.huesoporro.infra.authenticator import TwitchAuthenticator -from src.huesoporro.infra.repos import UserRepo -from src.huesoporro.models import User -from src.huesoporro.settings import Settings +from huesoporro.infra.authenticator import TwitchAuthenticator +from huesoporro.infra.repos import UserRepo +from huesoporro.models import User +from huesoporro.settings import Settings class GetUserByJWTAction(BaseModel): diff --git a/src/huesoporro/actions/import_from_vod.py b/src/huesoporro/actions/import_from_vod.py new file mode 100644 index 0000000..be62ea5 --- /dev/null +++ b/src/huesoporro/actions/import_from_vod.py @@ -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 diff --git a/src/huesoporro/actions/refresh.py b/src/huesoporro/actions/refresh.py index f28cc7c..7dafa77 100644 --- a/src/huesoporro/actions/refresh.py +++ b/src/huesoporro/actions/refresh.py @@ -1,9 +1,9 @@ from pydantic import BaseModel -from src.huesoporro.infra.authenticator import TwitchAuthenticator -from src.huesoporro.infra.repos import UserRepo -from src.huesoporro.models import User -from src.huesoporro.settings import Settings +from huesoporro.infra.authenticator import TwitchAuthenticator +from huesoporro.infra.repos import UserRepo +from huesoporro.models import User +from huesoporro.settings import Settings class RefreshAction(BaseModel): diff --git a/src/huesoporro/actions/store_quote.py b/src/huesoporro/actions/store_quote.py index 87b5a84..0bee177 100644 --- a/src/huesoporro/actions/store_quote.py +++ b/src/huesoporro/actions/store_quote.py @@ -2,9 +2,9 @@ import datetime from pydantic import BaseModel -from src.huesoporro.models import Quote, User -from src.huesoporro.svc.is_mod import IsModSvc -from src.huesoporro.svc.quote_storer_svc import QuoteStorerSvc +from huesoporro.models import Quote, User +from huesoporro.svc.is_mod import IsModSvc +from huesoporro.svc.quote_storer_svc import QuoteStorerSvc class StoreQuoteAction(BaseModel): diff --git a/src/huesoporro/bot.py b/src/huesoporro/bot.py index 14c39db..ae07057 100644 --- a/src/huesoporro/bot.py +++ b/src/huesoporro/bot.py @@ -2,25 +2,26 @@ import asyncio import random from collections.abc import Callable from enum import StrEnum +from typing import ClassVar from loguru import logger from twitchio import Channel from twitchio.ext import commands, routines -from src.huesoporro.actions.get_random_quote import GetRandomQuoteAction -from src.huesoporro.actions.store_quote import StoreQuoteAction -from src.huesoporro.api.dependencies import get_settings -from src.huesoporro.infra.db import Database -from src.huesoporro.infra.repos import QuoteRepo -from src.huesoporro.libs.db import Database as MarkovDB -from src.huesoporro.models import ChatbotSettings, User -from src.huesoporro.svc.backoff_service import BackoffService -from src.huesoporro.svc.generate import SentenceGeneratorSvc -from src.huesoporro.svc.get_random_quote import RandomQuoteGetterSvc -from src.huesoporro.svc.hello import HelloGeneratorSvc -from src.huesoporro.svc.is_mod import IsModSvc -from src.huesoporro.svc.quote_storer_svc import QuoteStorerSvc -from src.huesoporro.svc.store import SentenceStorerSvc +from huesoporro.actions.get_random_quote import GetRandomQuoteAction +from huesoporro.actions.store_quote import StoreQuoteAction +from huesoporro.infra.db import Database +from huesoporro.infra.repos import QuoteRepo +from huesoporro.libs.db import MarkovDatabase +from huesoporro.models import ChatbotSettings, User +from huesoporro.settings import Settings +from huesoporro.svc.backoff_service import BackoffService +from huesoporro.svc.generate import SentenceGeneratorSvc +from huesoporro.svc.get_random_quote import RandomQuoteGetterSvc +from huesoporro.svc.hello import get_hello_generator_svc +from huesoporro.svc.is_mod import IsModSvc +from huesoporro.svc.quote_storer_svc import QuoteStorerSvc +from huesoporro.svc.store import SentenceStorerSvc class Bot(commands.Bot): @@ -30,10 +31,10 @@ class Bot(commands.Bot): ) self.channel = channel self.user = user - self.generate_svc = SentenceGeneratorSvc(db=MarkovDB(channel=channel)) - self.hello_svc = HelloGeneratorSvc() + self.generate_svc = SentenceGeneratorSvc(db=MarkovDatabase(channel=channel)) + self.hello_svc = get_hello_generator_svc() 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_action = GetRandomQuoteAction( 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"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"]) async def generate(self, ctx: commands.Context, *, words: str | None = None): 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) if quote: channel = self.get_channel_conn() - logger.info(f"Sending random quote {quote.quote}") - await channel.send(quote.quote) + if channel: + logger.info(f"Sending random quote {quote.quote}") + await channel.send(quote.quote) async def send_generation(self): sentence = await self.generate_svc.run() @@ -123,6 +120,23 @@ class Bot(commands.Bot): 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): COMMAND = "COMMAND" HELLO = "HELLO" @@ -136,7 +150,6 @@ class MessageHandler: """Handles different types of messages with their corresponding responses""" def __init__(self, channel_send_func: Callable): - self.hello_patterns = ["hola", "HOLA", "hiii", "ayo"] self.laugh_patterns = [ "om", "KEK", @@ -154,8 +167,6 @@ class MessageHandler: """Determines the type of message based on its content""" if content.startswith("!"): return MessageType.COMMAND - if content in self.hello_patterns: - return MessageType.HELLO if content == "Yes": return MessageType.YES if content.startswith("WHAT"): @@ -164,10 +175,6 @@ class MessageHandler: return MessageType.LAUGH 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: """Handles laugh messages""" return random.choice(self.laugh_patterns) # noqa: S311 @@ -176,20 +183,17 @@ class MessageHandler: class SaveMessagesCog(commands.Cog): def __init__(self, bot): self.bot = bot - self.store_svc = SentenceStorerSvc(db=MarkovDB(channel=bot.channel)) - self.hello_svc = HelloGeneratorSvc() + self.store_svc = SentenceStorerSvc(db=MarkovDatabase(channel=bot.channel)) + self.generate_svc = SentenceGeneratorSvc(db=MarkovDatabase(channel=bot.channel)) self.backoff_svc = BackoffService() self.message_handler = MessageHandler(self._send_message) - # Register a separate send function for each message type self.send_functions = { - MessageType.HELLO: self._create_typed_send("hello"), MessageType.YES: self._create_typed_send("yes"), MessageType.WHAT: self._create_typed_send("what"), MessageType.LAUGH: self._create_typed_send("laugh"), } - # Register each send function with its own backoff for func in self.send_functions.values(): self.backoff_svc.add_callable(func, backoff_seconds=10) @@ -229,10 +233,6 @@ class SaveMessagesCog(commands.Cog): match msg_type: case MessageType.COMMAND: return - case MessageType.HELLO: - response = await self.message_handler.handle_hello( - message.author.name, self.hello_svc - ) case MessageType.YES: response = "Indeed" case MessageType.WHAT: @@ -258,6 +258,7 @@ class BotsManager: logger.info(f"Adding bot for {user.user}") bot = Bot(user=user, channel=channel, chatbot_settings=chatbot_settings) bot.add_cog(SaveMessagesCog(bot)) + bot.add_cog(HelloMessagesCog(bot)) self.bots[user.user] = bot async def run_user_bot(self, user: User): diff --git a/src/huesoporro/infra/authenticator.py b/src/huesoporro/infra/authenticator.py index 0405c8a..6f99839 100644 --- a/src/huesoporro/infra/authenticator.py +++ b/src/huesoporro/infra/authenticator.py @@ -2,8 +2,8 @@ import httpx from litestar.exceptions import HTTPException from pydantic import BaseModel, ConfigDict, Field -from src.huesoporro.models import TwitchAuth -from src.huesoporro.settings import Settings +from huesoporro.models import TwitchAuth +from huesoporro.settings import Settings class TwitchAuthenticator(BaseModel): diff --git a/src/huesoporro/infra/db.py b/src/huesoporro/infra/db.py index 898b768..44405f7 100644 --- a/src/huesoporro/infra/db.py +++ b/src/huesoporro/infra/db.py @@ -5,8 +5,8 @@ import aiosqlite from loguru import logger from pydantic import BaseModel, Field -from src.huesoporro.models import ChatbotSettings, Sentence, User -from src.huesoporro.settings import Settings +from huesoporro.models import ChatbotSettings, User +from huesoporro.settings import Settings class Database(BaseModel): @@ -78,22 +78,3 @@ class Database(BaseModel): if not result: return None 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] diff --git a/src/huesoporro/infra/gtts.py b/src/huesoporro/infra/gtts.py index 10814ec..98a3c74 100644 --- a/src/huesoporro/infra/gtts.py +++ b/src/huesoporro/infra/gtts.py @@ -6,7 +6,7 @@ from gtts import gTTS from loguru import logger from pydantic import BaseModel -from src.huesoporro.settings import Settings +from huesoporro.settings import Settings class GTTS(BaseModel): diff --git a/src/huesoporro/infra/repos.py b/src/huesoporro/infra/repos.py index 3256ffa..7e88554 100644 --- a/src/huesoporro/infra/repos.py +++ b/src/huesoporro/infra/repos.py @@ -6,8 +6,8 @@ from typing import Generic, TypeVar import aiosqlite from pydantic import BaseModel, Field -from src.huesoporro.models import Quote, User -from src.huesoporro.settings import Settings +from huesoporro.models import Quote, User +from huesoporro.settings import Settings T = TypeVar("T", bound=BaseModel) diff --git a/src/huesoporro/libs/db.py b/src/huesoporro/libs/db.py index ef523ca..615c706 100644 --- a/src/huesoporro/libs/db.py +++ b/src/huesoporro/libs/db.py @@ -4,11 +4,12 @@ import sqlite3 import string from typing import Any -import platformdirs from loguru import logger +from huesoporro.settings import Settings -class Database: + +class MarkovDatabase: """ The database created is called `MarkovChain_{channel}.db`, 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,". """ - def __init__(self, channel: str): - self.user_data_path = platformdirs.user_data_path( - "huesoporro", - ensure_exists=True, - ) - self.db_path = ( - self.user_data_path / f"MarkovChain_{channel.replace('#', '').lower()}.db" - ) + def __init__(self, channel: str, settings: Settings | None = None): + settings = settings or Settings.get() + + self.db_path = settings.default_data_path / f"MarkovChain_{channel}.db" + self.user_data_path = self.db_path.parent self._execute_queue: list = [] if self.db_path.is_file(): @@ -357,7 +355,7 @@ class Database: from nltk import ngrams - from src.huesoporro.libs.tokenizer import tokenize + from huesoporro.libs.tokenizer import tokenize channel = channel.replace("#", "").lower() copyfile( diff --git a/src/huesoporro/main.py b/src/huesoporro/main.py index 318c67d..fde026e 100644 --- a/src/huesoporro/main.py +++ b/src/huesoporro/main.py @@ -1,6 +1,6 @@ import uvicorn -from src.huesoporro.settings import Settings +from huesoporro.settings import Settings if __name__ == "__main__": settings = Settings.get() diff --git a/src/huesoporro/models.py b/src/huesoporro/models.py index 86f977b..a832646 100644 --- a/src/huesoporro/models.py +++ b/src/huesoporro/models.py @@ -4,7 +4,7 @@ from typing import Literal import jwt from pydantic import BaseModel, Field, field_validator -from src.huesoporro.settings import Settings +from huesoporro.settings import Settings class TwitchAuth(BaseModel): diff --git a/src/huesoporro/settings.py b/src/huesoporro/settings.py index 358abc0..a9dd968 100644 --- a/src/huesoporro/settings.py +++ b/src/huesoporro/settings.py @@ -1,6 +1,7 @@ from functools import lru_cache from pathlib import Path +import platformdirs from pydantic import Field, HttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings @@ -8,18 +9,19 @@ from pydantic_settings import BaseSettings class Settings(BaseSettings): port: int = 8000 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( default_factory=lambda: Path(__file__).parent / "static" ) templates_files_path: Path = Field( default_factory=lambda: Path(__file__).parent / "templates" ) - tts_cache_path: Path = Field( - default_factory=lambda: Path(__file__).parent / "tts_files" - ) - db_filepath: Path = Field( - default_factory=lambda: Path(__file__).parent / "huesoporro.db" - ) + tts_cache_path: Path = default_data_path / "tts_files" + db_filepath: Path = default_data_path / "huesoporro.db" twitch_client_id: str twitch_client_secret: SecretStr jwt_secret: SecretStr @@ -31,8 +33,8 @@ class Settings(BaseSettings): @staticmethod @lru_cache(maxsize=1) - def get(): - return Settings() # type: ignore[call-arg] # pydantic-setting magic + def get(**data): + return Settings(**data) # type: ignore[call-arg] # pydantic-setting magic @field_validator("allowed_users") @classmethod diff --git a/src/huesoporro/svc/clean_cc_svc.py b/src/huesoporro/svc/clean_cc_svc.py new file mode 100644 index 0000000..8680d71 --- /dev/null +++ b/src/huesoporro/svc/clean_cc_svc.py @@ -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) diff --git a/src/huesoporro/svc/download_closed_captions.py b/src/huesoporro/svc/download_closed_captions.py new file mode 100644 index 0000000..c8c2305 --- /dev/null +++ b/src/huesoporro/svc/download_closed_captions.py @@ -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 diff --git a/src/huesoporro/svc/generate.py b/src/huesoporro/svc/generate.py index 5bef7fe..d4a1766 100644 --- a/src/huesoporro/svc/generate.py +++ b/src/huesoporro/svc/generate.py @@ -3,8 +3,8 @@ import string from loguru import logger from pydantic import BaseModel, ConfigDict -from src.huesoporro.libs.db import Database as MarkovDB -from src.huesoporro.libs.tokenizer import detokenize, tokenize +from huesoporro.libs.db import MarkovDatabase as MarkovDB +from huesoporro.libs.tokenizer import detokenize, tokenize class SentenceGeneratorSvc(BaseModel): diff --git a/src/huesoporro/svc/get_chatbot_settings.py b/src/huesoporro/svc/get_chatbot_settings.py index a1bfc2f..a5b2e19 100644 --- a/src/huesoporro/svc/get_chatbot_settings.py +++ b/src/huesoporro/svc/get_chatbot_settings.py @@ -1,7 +1,7 @@ from pydantic import BaseModel -from src.huesoporro.infra.db import Database -from src.huesoporro.models import ChatbotSettings, User +from huesoporro.infra.db import Database +from huesoporro.models import ChatbotSettings, User class ChatbotSettingsGetterSvc(BaseModel): diff --git a/src/huesoporro/svc/get_random_quote.py b/src/huesoporro/svc/get_random_quote.py index 055b633..d82cffd 100644 --- a/src/huesoporro/svc/get_random_quote.py +++ b/src/huesoporro/svc/get_random_quote.py @@ -1,7 +1,7 @@ from pydantic import BaseModel -from src.huesoporro.infra.repos import QuoteRepo -from src.huesoporro.models import Quote +from huesoporro.infra.repos import QuoteRepo +from huesoporro.models import Quote class RandomQuoteGetterSvc(BaseModel): diff --git a/src/huesoporro/svc/get_sentences_svc.py b/src/huesoporro/svc/get_sentences_svc.py deleted file mode 100644 index 1cb0a1c..0000000 --- a/src/huesoporro/svc/get_sentences_svc.py +++ /dev/null @@ -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) diff --git a/src/huesoporro/svc/hello.py b/src/huesoporro/svc/hello.py index 5be2180..d0b49cc 100644 --- a/src/huesoporro/svc/hello.py +++ b/src/huesoporro/svc/hello.py @@ -1,20 +1,35 @@ import random +from functools import lru_cache +from loguru import logger from pydantic import BaseModel, Field class HelloGeneratorSvc(BaseModel): - hellos: list[str] = Field( - default_factory=lambda: [ - "Hola", - "Ayo", - "Hi", - "Bon día", - "Hola mi tremendo elemento", - "HOLA", - "hiii", - ] - ) + hellos: list[str] = [ + "Hola", + "Ayo", + "Hi", + "Bon día", + "Hola mi tremendo elemento", + "HOLA", + "hiii", + ] - def run(self, username: str): - return f"{random.choice(self.hellos)} @{username}" # noqa: S311 + greeted_users: dict[str, str] = Field(default_factory=dict) + + 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() diff --git a/src/huesoporro/svc/is_mod.py b/src/huesoporro/svc/is_mod.py index d15e988..49f2969 100644 --- a/src/huesoporro/svc/is_mod.py +++ b/src/huesoporro/svc/is_mod.py @@ -1,7 +1,7 @@ from pydantic import BaseModel -from src.huesoporro.infra.db import Database -from src.huesoporro.models import User +from huesoporro.infra.db import Database +from huesoporro.models import User class IsModSvc(BaseModel): diff --git a/src/huesoporro/svc/quote_storer_svc.py b/src/huesoporro/svc/quote_storer_svc.py index bc71320..0806e88 100644 --- a/src/huesoporro/svc/quote_storer_svc.py +++ b/src/huesoporro/svc/quote_storer_svc.py @@ -1,7 +1,7 @@ from pydantic import BaseModel -from src.huesoporro.infra.repos import QuoteRepo -from src.huesoporro.models import Quote +from huesoporro.infra.repos import QuoteRepo +from huesoporro.models import Quote class QuoteStorerSvc(BaseModel): diff --git a/src/huesoporro/svc/store.py b/src/huesoporro/svc/store.py index 48e3115..2343f1d 100644 --- a/src/huesoporro/svc/store.py +++ b/src/huesoporro/svc/store.py @@ -2,12 +2,12 @@ from loguru import logger from nltk.tokenize import sent_tokenize from pydantic import BaseModel, ConfigDict -from src.huesoporro.libs.db import Database as MarkovDB -from src.huesoporro.libs.tokenizer import tokenize +from huesoporro.libs.db import MarkovDatabase +from huesoporro.libs.tokenizer import tokenize class SentenceStorerSvc(BaseModel): - db: MarkovDB + db: MarkovDatabase key_length: int = 2 end_tag: str = "" diff --git a/src/huesoporro/svc/store_settings.py b/src/huesoporro/svc/store_settings.py index 96ef827..59ca6ab 100644 --- a/src/huesoporro/svc/store_settings.py +++ b/src/huesoporro/svc/store_settings.py @@ -1,7 +1,7 @@ from pydantic import BaseModel -from src.huesoporro.infra.db import Database -from src.huesoporro.models import ChatbotSettings, User +from huesoporro.infra.db import Database +from huesoporro.models import ChatbotSettings, User class ChatbotSettingsStorerSvc(BaseModel): diff --git a/src/huesoporro/templates/index.html b/src/huesoporro/templates/index.html index 082c17e..a2debe5 100644 --- a/src/huesoporro/templates/index.html +++ b/src/huesoporro/templates/index.html @@ -185,7 +185,6 @@ const chatbotManager = new ChatbotManager(); chatbotManager.setEvents(); - }); diff --git a/src/huesoporro/templates/le_funny_dropdown.html b/src/huesoporro/templates/le_funny_dropdown.html index a7f63a6..6faa30d 100644 --- a/src/huesoporro/templates/le_funny_dropdown.html +++ b/src/huesoporro/templates/le_funny_dropdown.html @@ -2,9 +2,8 @@ diff --git a/src/huesoporro/templates/sentences.html b/src/huesoporro/templates/sentences.html deleted file mode 100644 index eb9f87d..0000000 --- a/src/huesoporro/templates/sentences.html +++ /dev/null @@ -1,137 +0,0 @@ -{% include 'header.html' %} - - -
- - -
-
-
-
- - - -
- - - - - - - - - - {% for sentence in sentences %} - - - - - - {% endfor %} - -
SentenceLast modifiedAction
{{ sentence.last_updated_at }} -
- - -
-
-
-
- - diff --git a/src/huesoporro/tts.py b/src/huesoporro/tts.py index 2e671f4..971b964 100644 --- a/src/huesoporro/tts.py +++ b/src/huesoporro/tts.py @@ -7,7 +7,7 @@ from gtts import gTTS from litestar import WebSocket from loguru import logger -from src.huesoporro.settings import Settings +from huesoporro.settings import Settings class TTSManager: diff --git a/tests/conftest.py b/tests/conftest.py index 2163026..838c78a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,11 +6,11 @@ from caribou.migrate import load_migrations from polyfactory.factories.pydantic_factory import ModelFactory from polyfactory.pytest_plugin import register_fixture -from src.huesoporro.infra.db import Database -from src.huesoporro.models import ChatbotSettings, Quote, User -from src.huesoporro.settings import Settings -from src.huesoporro.svc.backoff_service import BackoffService -from src.huesoporro.svc.is_mod import IsModSvc +from huesoporro.infra.db import Database +from huesoporro.models import ChatbotSettings, Quote, User +from huesoporro.settings import Settings +from huesoporro.svc.backoff_service import BackoffService +from huesoporro.svc.is_mod import IsModSvc @pytest.fixture diff --git a/tests/test_repos.py b/tests/test_repos.py index 5327108..91067c0 100644 --- a/tests/test_repos.py +++ b/tests/test_repos.py @@ -2,8 +2,8 @@ import json import pytest -from src.huesoporro.infra.repos import QuoteRepo, UserRepo -from src.huesoporro.models import User +from huesoporro.infra.repos import QuoteRepo, UserRepo +from huesoporro.models import User @pytest.fixture diff --git a/tests/test_svc.py b/tests/test_svc.py index c88e1c9..d87cd9c 100644 --- a/tests/test_svc.py +++ b/tests/test_svc.py @@ -3,8 +3,8 @@ import time import pytest -from src.huesoporro.models import ChatbotSettings, User -from src.huesoporro.svc.is_mod import IsModSvc +from huesoporro.models import ChatbotSettings, User +from huesoporro.svc.is_mod import IsModSvc async def test_is_mod_svc_returns_true_for_channel(is_mod_svc: IsModSvc, user: User): diff --git a/uv.lock b/uv.lock index e90f9d4..f7e072d 100644 --- a/uv.lock +++ b/uv.lock @@ -517,8 +517,8 @@ wheels = [ [[package]] name = "huesoporro" -version = "0.2.9" -source = { virtual = "." } +version = "0.3.0" +source = { editable = "." } dependencies = [ { name = "aiosqlite" }, { name = "caribou" }, @@ -538,6 +538,10 @@ dependencies = [ ] [package.dev-dependencies] +cli = [ + { name = "typer" }, + { name = "yt-dlp" }, +] dev = [ { name = "mypy" }, { name = "polyfactory" }, @@ -567,6 +571,10 @@ requires-dist = [ ] [package.metadata.requires-dev] +cli = [ + { name = "typer", specifier = ">=0.15.1" }, + { name = "yt-dlp", specifier = ">=2025.1.26" }, +] dev = [ { name = "mypy", specifier = ">=1.13.0" }, { 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 }, ] +[[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]] name = "six" 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 }, ] +[[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]] name = "typing-extensions" 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/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 }, +]