diff --git a/.dockerignore b/.dockerignore index fc5c36f..51d084a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -50,4 +50,3 @@ build/ # OS .DS_Store Thumbs.db - diff --git a/.env.example b/.env.example index 5b2bc55..e643860 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,8 @@ -TMDB_API_KEY= \ No newline at end of file +TMDB_API_KEY= +PORT=8000 +ADDON_ID=com.bimal.watchly +ADDON_NAME=Watchly +REDIS_URL=redis://redis:6379/0 +TOKEN_SALT=replace-with-a-long-random-string +TOKEN_TTL_SECONDS=0 +ANNOUNCEMENT_HTML= diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 8fcfd30..1b5c6c7 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,2 @@ -patreon: TimilsinaBimal -ko-fi: TimilsinaBimal -custom: ["https://www.paypal.com/donate/?hosted_button_id=KRQMVS34FC5KC"] \ No newline at end of file +ko_fi: TimilsinaBimal +custom: ["https://www.paypal.com/donate/?hosted_button_id=KRQMVS34FC5KC"] diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..ad6af93 --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,29 @@ +name: Linter + +# Enable Buildkit and let compose use it to speed up image building +env: + DOCKER_BUILDKIT: 1 + COMPOSE_DOCKER_CLI_BUILD: 1 + +on: + pull_request: + push: + +concurrency: + group: ${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + linter: + runs-on: ubuntu-latest + steps: + - name: Checkout Code Repository + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Run pre-commit + uses: pre-commit/action@v3.0.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..dbaaa51 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,45 @@ +default_stages: [pre-commit] +exclude: '^misc/|^data/|^docs/' + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-json + - id: check-toml + - id: check-xml + - id: check-yaml + - id: debug-statements + - id: check-builtin-literals + - id: check-case-conflict + - id: check-docstring-first + - id: detect-private-key + + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.2 + hooks: + - id: pyupgrade + args: [--py311-plus] + + - repo: https://github.com/psf/black + rev: 24.4.0 + hooks: + - id: black + + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + + - repo: https://github.com/PyCQA/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + +# sets up .pre-commit-ci.yaml to ensure pre-commit dependencies stay up to date +ci: + autoupdate_schedule: weekly + skip: [] + submodules: false diff --git a/Dockerfile b/Dockerfile index d442837..d70e3bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,4 +27,4 @@ COPY static/ ./static/ COPY main.py . COPY pyproject.toml . -ENTRYPOINT ["python", "main.py"] \ No newline at end of file +ENTRYPOINT ["python", "main.py"] diff --git a/README.md b/README.md index e426e08..e50bb76 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ +# Watchly [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I81OVJEH) [![PayPal](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://www.paypal.com/donate/?hosted_button_id=KRQMVS34FC5KC) -# Watchly **Watchly** is a Stremio catalog addon that provides personalized movie and series recommendations based on your Stremio library. It uses The Movie Database (TMDB) API to generate intelligent recommendations from the content you've watched and loved. @@ -33,7 +33,10 @@ Watchly is a FastAPI-based Stremio addon that: - ✅ **Similar Content Discovery** - find content similar to specific titles - ✅ **Web Configuration Interface** - easy setup through a web UI - ✅ **Caching** - optimized performance with intelligent caching +- ✅ **Secure Tokenized Access** - credentials/auth keys never travel in URLs - ✅ **Docker Support** - easy deployment with Docker and Docker Compose +- ✅ **Background Catalog Refresh** - automatically keeps Stremio catalogs in sync +- ✅ **Credential Validation** - verifies access details and primes catalogs before issuing tokens ## Installation @@ -63,7 +66,7 @@ Watchly is a FastAPI-based Stremio addon that: ``` TMDB_API_KEY=your_tmdb_api_key_here PORT=8000 - ADDON_ID=com.bimal.watchly + ... ``` 4. **Start the application:** @@ -76,23 +79,6 @@ Watchly is a FastAPI-based Stremio addon that: - Configuration page: `http://localhost:8000/configure` - API Documentation: `http://localhost:8000/docs` -#### Using Docker Only - -1. **Build the image:** - ```bash - docker build -t watchly . - ``` - -2. **Run the container:** - ```bash - docker run -d \ - --name watchly \ - -p 8000:8000 \ - -e TMDB_API_KEY=your_tmdb_api_key_here \ - -e PORT=8000 \ - -e ADDON_ID=com.bimal.watchly \ - watchly - ``` ### Option 2: Manual Installation @@ -102,70 +88,63 @@ Watchly is a FastAPI-based Stremio addon that: cd Watchly ``` -2. **Create a virtual environment (recommended):** - ```bash - python3 -m venv venv - source venv/bin/activate # On Windows: venv\Scripts\activate - ``` - -3. **Install dependencies:** - ```bash - pip install -r requirements.txt - ``` - -4. **Set environment variables:** - +2. **Set environment variables:** Create a `.env` file in the project root: ``` TMDB_API_KEY=your_tmdb_api_key_here PORT=8000 - ADDON_ID=com.bimal.watchly - ``` - - Or export them in your shell: - ```bash - export TMDB_API_KEY=your_tmdb_api_key_here - export PORT=8000 - export ADDON_ID=com.bimal.watchly + ... ``` -5. **Run the application:** +3. **Install UV and Run app (recommended):** +- [Installation Instructions](https://docs.astral.sh/uv/getting-started/installation/) ```bash - uvicorn app.core.app:app --host 0.0.0.0 --port 8000 + uv run main.py ``` - Or using Python directly: - ```bash - python main.py - ``` - -6. **Access the application:** +4. **Access the application:** - API: `http://localhost:8000` - Configuration page: `http://localhost:8000/configure` - API Documentation: `http://localhost:8000/docs` + +*You Can also create virtual environment and install dependencies from requirements.txt and run the app* + ## Configuration ### Environment Variables | Variable | Description | Required | Default | |----------|-------------|----------|---------| -| `TMDB_API_KEY` | Your TMDB API key | Yes | - | +| `TMDB_API_KEY` | Your TMDB API key | Required for catalog features (optional for `/health`) | *(empty)* | | `PORT` | Server port | No | 8000 | | `ADDON_ID` | Stremio addon identifier | No | com.bimal.watchly | +| `ADDON_NAME` | Human-friendly addon name shown in the manifest/UI | No | Watchly | +| `REDIS_URL` | Redis connection string for credential tokens | No | `redis://localhost:6379/0` | +| `TOKEN_SALT` | Secret salt for hashing token IDs | Yes | - (must be set in production) | +| `TOKEN_TTL_SECONDS` | Token lifetime in seconds (`0` = no expiry) | No | 0 | +| `ANNOUNCEMENT_HTML` | Optional HTML snippet rendered in the configurator banner | No | *(empty)* | +| `TMDB_ADDON_URL` | Base URL for the TMDB addon metadata proxy | No | `https://94c8cb9f702d-tmdb-addon.baby-beamup.club/...` | +| `AUTO_UPDATE_CATALOGS` | Enable periodic background catalog refreshes | No | `true` | +| `CATALOG_REFRESH_INTERVAL_SECONDS` | Interval between automatic refreshes (seconds) | No | `21600` (6h) | ### User Configuration -Users configure their Stremio credentials through the web interface at `/configure`. Credentials are: -- Encoded in the addon URL (base64) -- Never stored on the server -- Used only for API requests to Stremio +Use the web interface at `/configure` to provision a secure access token: + +1. Provide either your **Stremio username/password** *or* an **existing `authKey`** (copy from `localStorage.authKey` in [https://web.stremio.com/](https://web.stremio.com/)). +2. Choose whether to base recommendations on loved items only or include everything you've watched. +3. Watchly verifies the credentials/auth key with Stremio, performs the first catalog refresh in the background, and only then stores the payload inside Redis. +4. Your manifest URL becomes `https:////manifest.json`. Only this token ever appears in URLs. +5. Re-running the setup with the same credentials/configuration returns the exact same token. + +By default (`TOKEN_TTL_SECONDS=0`), tokens never expire. Set a positive TTL if you want automatic rotation. ## How It Works -1. **User Configuration**: User enters Stremio credentials via web interface -2. **Credential Encoding**: Credentials are base64 encoded and included in the addon URL -3. **Library Fetching**: When catalog is requested, service authenticates with Stremio and fetches user's library +1. **User Configuration**: User submits Stremio credentials or auth key via the web interface +2. **Secure Tokenization**: Credentials/auth keys are stored server-side in Redis; the user only receives a salted token +3. **Library Fetching**: When catalog is requested, service resolves the token, authenticates with Stremio, and fetches the library 4. **Seed Selection**: Uses most recent "loved" items (default: 10) as seed content 5. **Recommendation Generation**: For each seed, fetches recommendations from TMDB 6. **Filtering**: Removes items already in user's watched library @@ -216,7 +195,7 @@ Watchly/ ### Running in Development Mode ```bash -uvicorn app.core.app:app --reload --host 0.0.0.0 --port 8000 +uv run main.py --dev ``` Or using Python directly (with auto-reload based on APP_ENV): @@ -224,20 +203,30 @@ Or using Python directly (with auto-reload based on APP_ENV): python main.py ``` +### Health Check Endpoint + +The `/health` endpoint responds with `{ "status": "ok" }` without touching external services. This keeps container builds and probes green even when secrets like `TMDB_API_KEY` aren't supplied yet. + +### Background Catalog Updates + +Watchly now refreshes catalogs automatically using the credentials stored in Redis. By default the background worker runs every 6 hours and updates each token's catalogs directly via the Stremio API. To disable the behavior, set `AUTO_UPDATE_CATALOGS=false` (or choose a custom cadence with `CATALOG_REFRESH_INTERVAL_SECONDS`). Manual refreshes through `/{token}/catalog/update` continue to work and reuse the same logic. + ### Testing ```bash # Test manifest endpoint curl http://localhost:8000/manifest.json -# Test catalog endpoint (requires encoded credentials) -curl http://localhost:8000/{encoded}/catalog/movie/watchly.rec.json +# Test catalog endpoint (requires a credential token) +curl http://localhost:8000/{token}/catalog/movie/watchly.rec.json ``` ## Security Notes -- **Credentials in URL**: User credentials are base64 encoded in the addon URL. While encoded, they are not encrypted. Users should be aware of this. -- **HTTPS Recommended**: Always use HTTPS in production to protect credentials in transit. +- **Tokenized URLs**: Manifest/catalog URLs now contain only salted tokens. Credentials/auth keys never leave the server once submitted. +- **Rotate `TOKEN_SALT`**: Treat the salt like any other secret; rotate if you suspect compromise. Changing the salt invalidates all tokens. +- **Redis Security**: Ensure your Redis instance is not exposed publicly and enable authentication if hosted remotely. +- **HTTPS Recommended**: Always use HTTPS in production to protect tokens in transit. - **Environment Variables**: Never commit `.env` files or expose API keys in code. ## Troubleshooting diff --git a/app/api/endpoints/announcement.py b/app/api/endpoints/announcement.py new file mode 100644 index 0000000..5f31df1 --- /dev/null +++ b/app/api/endpoints/announcement.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from app.config import settings + +router = APIRouter(prefix="/announcement", tags=["announcement"]) + + +@router.get("/") +async def get_announcement() -> dict: + return {"html": settings.ANNOUNCEMENT_HTML or ""} diff --git a/app/api/endpoints/caching.py b/app/api/endpoints/caching.py index fa554d5..4962d2a 100644 --- a/app/api/endpoints/caching.py +++ b/app/api/endpoints/caching.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, HTTPException from loguru import logger + from app.utils import clear_cache router = APIRouter(prefix="/cache") diff --git a/app/api/endpoints/catalogs.py b/app/api/endpoints/catalogs.py index a78ec95..eb56187 100644 --- a/app/api/endpoints/catalogs.py +++ b/app/api/endpoints/catalogs.py @@ -1,17 +1,18 @@ from fastapi import APIRouter, HTTPException, Response from loguru import logger + +from app.services.catalog_updater import refresh_catalogs_for_credentials from app.services.recommendation_service import RecommendationService from app.services.stremio_service import StremioService -from app.utils import decode_credentials -from app.services.catalog import DynamicCatalogService +from app.utils import resolve_user_credentials router = APIRouter() @router.get("/catalog/{type}/{id}.json") -@router.get("/{encoded}/catalog/{type}/{id}.json") +@router.get("/{token}/catalog/{type}/{id}.json") async def get_catalog( - encoded: str, + token: str | None, type: str, id: str, response: Response, @@ -21,29 +22,37 @@ async def get_catalog( Returns recommendations based on user's Stremio library. Args: - encoded: Base64 encoded credentials + token: Redis-backed credential token type: 'movie' or 'series' id: Catalog ID (e.g., 'watchly.rec') """ + if not token: + raise HTTPException( + status_code=400, + detail="Missing credentials token. Please open Watchly from a configured manifest URL.", + ) + logger.info(f"Fetching catalog for {type} with id {id}") - # Decode credentials from path - credentials = decode_credentials(encoded) + credentials = await resolve_user_credentials(token) if type not in ["movie", "series"]: logger.warning(f"Invalid type: {type}") - raise HTTPException( - status_code=400, detail="Invalid type. Use 'movie' or 'series'" - ) + raise HTTPException(status_code=400, detail="Invalid type. Use 'movie' or 'series'") if id not in ["watchly.rec"] and not id.startswith("tt") and not id.startswith("watchly.genre."): logger.warning(f"Invalid id: {id}") raise HTTPException( - status_code=400, detail="Invalid id. Use 'watchly.rec' or 'watchly.genre.'" + status_code=400, + detail="Invalid id. Use 'watchly.rec' or 'watchly.genre.'", ) try: # Create services with credentials - stremio_service = StremioService(username=credentials['username'], password=credentials['password']) + stremio_service = StremioService( + username=credentials.get("username") or "", + password=credentials.get("password") or "", + auth_key=credentials.get("authKey"), + ) recommendation_service = RecommendationService(stremio_service=stremio_service) # if id starts with tt, then return recommendations for that particular item @@ -51,21 +60,19 @@ async def get_catalog( recommendations = await recommendation_service.get_recommendations_for_item(item_id=id) logger.info(f"Found {len(recommendations)} recommendations for {id}") elif id.startswith("watchly.genre."): - recommendations = await recommendation_service.get_recommendations_for_genre( - genre_id=id, media_type=type - ) + recommendations = await recommendation_service.get_recommendations_for_genre(genre_id=id, media_type=type) logger.info(f"Found {len(recommendations)} recommendations for {id}") else: # Get recommendations based on library # Use config to determine if we should include watched items - include_watched = credentials.get('includeWatched', False) + include_watched = credentials.get("includeWatched", False) # Use last 10 items as sources, get 5 recommendations per source item recommendations = await recommendation_service.get_recommendations( content_type=type, source_items_limit=10, recommendations_per_source=5, max_results=50, - include_watched=include_watched + include_watched=include_watched, ) logger.info(f"Found {len(recommendations)} recommendations for {type} (includeWatched: {include_watched})") @@ -81,24 +88,15 @@ async def get_catalog( raise HTTPException(status_code=500, detail=str(e)) -@router.get("/{encoded}/catalog/update") -async def update_catalogs(encoded: str): +@router.get("/{token}/catalog/update") +async def update_catalogs(token: str): """ Update the catalogs for the addon. This is a manual endpoint to update the catalogs. """ # Decode credentials from path - credentials = decode_credentials(encoded) - - stremio_service = StremioService(username=credentials['username'], password=credentials['password']) - library_items = await stremio_service.get_library_items() - dynamic_catalog_service = DynamicCatalogService(stremio_service=stremio_service) - catalogs = await dynamic_catalog_service.get_watched_loved_catalogs(library_items=library_items) - genre_based_catalogs = await dynamic_catalog_service.get_genre_based_catalogs(library_items=library_items) - catalogs += genre_based_catalogs - # update catalogs + credentials = await resolve_user_credentials(token) - logger.info(f"Updating Catalogs: {catalogs}") - auth_key = await stremio_service._get_auth_token() - updated = await stremio_service.update_catalogs(catalogs, auth_key) - logger.info(f"Updated catalogs: {updated}") + logger.info("Updating catalogs in response to manual request") + updated = await refresh_catalogs_for_credentials(credentials) + logger.info(f"Manual catalog update completed: {updated}") return {"success": updated} diff --git a/app/api/endpoints/health.py b/app/api/endpoints/health.py new file mode 100644 index 0000000..0e339e9 --- /dev/null +++ b/app/api/endpoints/health.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter(tags=["health"]) + + +@router.get("/health", summary="Simple readiness probe") +async def health_check() -> dict[str, str]: + return {"status": "ok"} diff --git a/app/api/endpoints/manifest.py b/app/api/endpoints/manifest.py index 20d431e..51b0887 100644 --- a/app/api/endpoints/manifest.py +++ b/app/api/endpoints/manifest.py @@ -1,20 +1,18 @@ from fastapi.routing import APIRouter from app.core.config import settings +from app.services.catalog import DynamicCatalogService +from app.services.stremio_service import StremioService +from app.utils import resolve_user_credentials router = APIRouter() -@router.get("/manifest.json") -@router.get("/{encoded}/manifest.json") -async def manifest(encoded: str): - """Stremio manifest endpoint with encoded credentials in path.""" - # Cache manifest for 1 day (86400 seconds) - # response.headers["Cache-Control"] = "public, max-age=86400" +def get_base_manifest(): return { "id": settings.ADDON_ID, - "version": "0.1.0", - "name": "Watchly", + "version": "0.1.1", + "name": settings.ADDON_NAME, "description": "Movie and series recommendations based on your Stremio library", "logo": "https://raw.githubusercontent.com/TimilsinaBimal/Watchly/refs/heads/main/static/logo.png", "resources": [ @@ -29,3 +27,34 @@ async def manifest(encoded: str): ], "behaviorHints": {"configurable": True, "configurationRequired": False}, } + + +async def fetch_catalogs(token: str | None = None): + if not token: + return [] + credentials = await resolve_user_credentials(token) + stremio_service = StremioService( + username=credentials.get("username") or "", + password=credentials.get("password") or "", + auth_key=credentials.get("authKey"), + ) + library_items = await stremio_service.get_library_items() + dynamic_catalog_service = DynamicCatalogService(stremio_service=stremio_service) + catalogs = await dynamic_catalog_service.get_watched_loved_catalogs(library_items=library_items) + catalogs += await dynamic_catalog_service.get_genre_based_catalogs(library_items=library_items) + return catalogs + + +@router.get("/manifest.json") +@router.get("/{token}/manifest.json") +async def manifest(token: str | None = None): + """Stremio manifest endpoint with optional credential token in the path.""" + # Cache manifest for 1 day (86400 seconds) + # response.headers["Cache-Control"] = "public, max-age=86400" + + base_manifest = get_base_manifest() + if token: + catalogs = await fetch_catalogs(token) + if catalogs: + base_manifest["catalogs"] += catalogs + return base_manifest diff --git a/app/api/endpoints/streams.py b/app/api/endpoints/streams.py index 959ecc3..45ee6ad 100644 --- a/app/api/endpoints/streams.py +++ b/app/api/endpoints/streams.py @@ -1,25 +1,29 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Request router = APIRouter() @router.get("/stream/{type}/{id}.json") -@router.get("/{encoded}/stream/{type}/{id}.json") +@router.get("/{token}/stream/{type}/{id}.json") async def get_stream( - encoded: str, + token: str | None, type: str, id: str, + request: Request, ): """ Stremio stream endpoint for movies and series. """ + base_url = str(request.base_url).rstrip("/") + update_path = f"/{token}/catalog/update/" if token else "/configure" + return { "streams": [ { "name": "Update Catalogs", "description": "Update the catalogs for the addon.", - "url": f"https://watchly-eta.vercel.app/{encoded}/catalog/update/", + "url": f"{base_url}{update_path}", } ] } diff --git a/app/api/endpoints/tokens.py b/app/api/endpoints/tokens.py new file mode 100644 index 0000000..db7bc53 --- /dev/null +++ b/app/api/endpoints/tokens.py @@ -0,0 +1,167 @@ +import httpx +from fastapi import APIRouter, HTTPException, Request +from loguru import logger +from pydantic import BaseModel, Field +from redis import exceptions as redis_exceptions + +from app.core.config import settings +from app.services.catalog_updater import refresh_catalogs_for_credentials +from app.services.stremio_service import StremioService +from app.services.token_store import token_store + +router = APIRouter(prefix="/tokens", tags=["tokens"]) + + +class TokenRequest(BaseModel): + username: str | None = Field(default=None, description="Stremio username or email") + password: str | None = Field(default=None, description="Stremio password") + authKey: str | None = Field(default=None, description="Existing Stremio auth key") + includeWatched: bool = Field( + default=False, + description="If true, recommendations can include watched titles", + ) + + +class TokenResponse(BaseModel): + token: str + manifestUrl: str + expiresInSeconds: int | None = Field( + default=None, + description="Number of seconds before the token expires (None means it does not expire)", + ) + + +async def _verify_credentials_or_raise(payload: dict) -> str: + """Ensure the supplied credentials/auth key are valid before issuing tokens.""" + stremio_service = StremioService( + username=payload.get("username") or "", + password=payload.get("password") or "", + auth_key=payload.get("authKey"), + ) + + try: + if payload.get("authKey") and not payload.get("username"): + await stremio_service.get_addons(auth_key=payload["authKey"]) + return payload["authKey"] + auth_key = await stremio_service.get_auth_key() + return auth_key + except ValueError as exc: + raise HTTPException( + status_code=400, + detail=str(exc) or "Invalid Stremio credentials or auth key.", + ) from exc + except httpx.HTTPStatusError as exc: # pragma: no cover - depends on remote API + status_code = exc.response.status_code + logger.warning("Credential validation failed with status %s", status_code) + if status_code in {401, 403}: + raise HTTPException( + status_code=400, + detail="Invalid Stremio credentials or auth key. Please double-check and try again.", + ) from exc + raise HTTPException( + status_code=502, + detail="Stremio returned an unexpected response. Please try again shortly.", + ) from exc + except Exception as exc: # pragma: no cover - defensive + logger.error("Unexpected error while validating credentials: {}", exc, exc_info=True) + raise HTTPException( + status_code=502, + detail="Unable to reach Stremio right now. Please try again later.", + ) from exc + finally: + await stremio_service.close() + + +def _preferred_base_url(request: Request) -> str: + headers = request.headers + + def _first_header_value(name: str) -> str | None: + raw = headers.get(name) + if not raw: + return None + # Some proxies send comma-separated lists for chained hops + return raw.split(",")[0].strip() + + scheme = _first_header_value("x-forwarded-proto") or request.url.scheme + host = _first_header_value("x-forwarded-host") or headers.get("host") or request.url.netloc + prefix = _first_header_value("x-forwarded-prefix") or "" + root_path = request.scope.get("root_path", "") + + base_path = f"{prefix}{root_path}".rstrip("/") + if base_path and not base_path.startswith("/"): + base_path = f"/{base_path}" + + base_url = f"{scheme}://{host}" + if base_path: + base_url = f"{base_url}{base_path}" + + return base_url.rstrip("/") + + +@router.post("/", response_model=TokenResponse) +async def create_token(payload: TokenRequest, request: Request) -> TokenResponse: + username = payload.username.strip() if payload.username else None + password = payload.password + auth_key = payload.authKey.strip() if payload.authKey else None + if auth_key and auth_key.startswith('"') and auth_key.endswith('"'): + auth_key = auth_key[1:-1].strip() + + if username and not password: + raise HTTPException(status_code=400, detail="Password is required when a username is provided.") + + if password and not username: + raise HTTPException( + status_code=400, + detail="Username/email is required when a password is provided.", + ) + + if not auth_key and not (username and password): + raise HTTPException( + status_code=400, + detail="Provide either a Stremio auth key or both username and password.", + ) + + payload_to_store = { + "username": username, + "password": password, + "authKey": auth_key, + "includeWatched": payload.includeWatched, + } + + verified_auth_key = await _verify_credentials_or_raise(payload_to_store) + + try: + token, created = await token_store.store_payload(payload_to_store) + except RuntimeError as exc: + logger.error("Token storage failed: {}", exc) + raise HTTPException( + status_code=500, + detail="Server configuration error: TOKEN_SALT must be set to a secure value.", + ) from exc + except (redis_exceptions.RedisError, OSError) as exc: + logger.error("Token storage unavailable: {}", exc) + raise HTTPException( + status_code=503, + detail="Token storage is temporarily unavailable. Please try again once Redis is reachable.", + ) from exc + + if created: + try: + await refresh_catalogs_for_credentials(payload_to_store, auth_key=verified_auth_key) + except Exception as exc: # pragma: no cover - remote dependency + logger.error("Initial catalog refresh failed: {}", exc, exc_info=True) + await token_store.delete_token(token) + raise HTTPException( + status_code=502, + detail="Credentials verified, but Watchly couldn't refresh your catalogs yet. Please try again.", + ) from exc + base_url = _preferred_base_url(request) + manifest_url = f"{base_url}/{token}/manifest.json" + + expires_in = settings.TOKEN_TTL_SECONDS if settings.TOKEN_TTL_SECONDS > 0 else None + + return TokenResponse( + token=token, + manifestUrl=manifest_url, + expiresInSeconds=expires_in, + ) diff --git a/app/api/main.py b/app/api/main.py index 1c8b4e6..8d1b909 100644 --- a/app/api/main.py +++ b/app/api/main.py @@ -1,8 +1,11 @@ from fastapi import APIRouter -from .endpoints.manifest import router as manifest_router -from .endpoints.catalogs import router as catalogs_router + from .endpoints.caching import router as caching_router +from .endpoints.catalogs import router as catalogs_router +from .endpoints.health import router as health_router +from .endpoints.manifest import router as manifest_router from .endpoints.streams import router as streams_router +from .endpoints.tokens import router as tokens_router api_router = APIRouter() @@ -16,3 +19,5 @@ async def root(): api_router.include_router(catalogs_router) api_router.include_router(caching_router) api_router.include_router(streams_router) +api_router.include_router(tokens_router) +api_router.include_router(health_router) diff --git a/app/core/__init__.py b/app/core/__init__.py index b004f27..87ee58c 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -2,4 +2,3 @@ from .config import settings __all__ = ["app", "settings"] - diff --git a/app/core/app.py b/app/core/app.py index 6a87449..ea81423 100644 --- a/app/core/app.py +++ b/app/core/app.py @@ -1,12 +1,17 @@ +import logging import os +from contextlib import asynccontextmanager +from pathlib import Path + from fastapi import FastAPI -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse from fastapi.middleware.cors import CORSMiddleware -import logging +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles from loguru import logger from app.api.main import api_router +from app.services.catalog_updater import BackgroundCatalogUpdater + from .config import settings @@ -22,11 +27,40 @@ def emit(self, record): logging.basicConfig(handlers=[InterceptHandler()], level=logging.INFO, force=True) +# Global catalog updater instance +catalog_updater: BackgroundCatalogUpdater | None = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Manage application lifespan events (startup/shutdown). + """ + global catalog_updater + + # Startup + if settings.AUTO_UPDATE_CATALOGS and settings.CATALOG_REFRESH_INTERVAL_SECONDS > 0: + catalog_updater = BackgroundCatalogUpdater(interval_seconds=settings.CATALOG_REFRESH_INTERVAL_SECONDS) + catalog_updater.start() + logger.info( + "Background catalog updates enabled (interval=%ss)", + settings.CATALOG_REFRESH_INTERVAL_SECONDS, + ) + + yield + + # Shutdown + if catalog_updater: + await catalog_updater.stop() + catalog_updater = None + logger.info("Background catalog updates stopped") + app = FastAPI( title="Watchly", description="Stremio catalog addon for movie and series recommendations", version="0.1.0", + lifespan=lifespan, ) app.add_middleware( @@ -39,21 +73,36 @@ def emit(self, record): # Serve static files # Static directory is at project root (3 levels up from app/core/app.py) -static_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static") -if os.path.exists(static_dir): - app.mount("/static", StaticFiles(directory=static_dir), name="static") +# app/core/app.py -> app/core -> app -> root +project_root = Path(__file__).resolve().parent.parent.parent +static_dir = project_root / "static" +if static_dir.exists(): + app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") -# Serve index.html at /configure and /{encoded}/configure -@app.get("/") -@app.get("/configure") -@app.get("/{encoded}/configure") -async def configure_page(encoded: str = None): - index_path = os.path.join(static_dir, "index.html") - if os.path.exists(index_path): - return FileResponse(index_path) - return {"message": "Watchly API is running. Static files not found."} +# Serve index.html at /configure and /{token}/configure +@app.get("/", response_class=HTMLResponse) +@app.get("/configure", response_class=HTMLResponse) +@app.get("/{token}/configure", response_class=HTMLResponse) +async def configure_page(token: str | None = None): + index_path = static_dir / "index.html" + if index_path.exists(): + html_content = index_path.read_text(encoding="utf-8") + dynamic_announcement = os.getenv("ANNOUNCEMENT_HTML") + if dynamic_announcement is None: + dynamic_announcement = settings.ANNOUNCEMENT_HTML + announcement_html = (dynamic_announcement or "").strip() + snippet = "" + if announcement_html: + snippet = '\n
' f"{announcement_html}" "
" + html_content = html_content.replace("", snippet, 1) + return HTMLResponse(content=html_content, media_type="text/html") + return HTMLResponse( + content="Watchly API is running. Static files not found.", + media_type="text/plain", + status_code=200, + ) -app.include_router(api_router) +app.include_router(api_router) diff --git a/app/core/config.py b/app/core/config.py index c81ef0c..b702405 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,18 +1,55 @@ -from pydantic_settings import BaseSettings, SettingsConfigDict from typing import Literal +from pydantic_settings import BaseSettings, SettingsConfigDict + + class Settings(BaseSettings): """Application settings loaded from environment variables.""" model_config = SettingsConfigDict( - env_file=".env", env_file_encoding="utf-8", case_sensitive=False + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="allow", ) - TMDB_API_KEY: str + TMDB_API_KEY: str | None = None + TMDB_API_URL: str = "https://api.themoviedb.org/3" + TMDB_ADDON_CONFIG_DICT: dict[str, str] = { + "provideImdbId": "true", + "returnImdbId": "true", + "language": "en-US", + "enableEpisodeProvider": "true", + "useDomain": "", + "cacheItemExpiryInHours": "24", + "returnPoster": "true", + "returnBackdrop": "true", + "returnStreamingData": "true", + "streamingDataLanguage": "en", + "disableImdbLookup": "false", + "type": "catalog", + "enableHopAgeRating": "false", + "enableAgeRating": "false", + "showAgeRatingWithImdbRating": "false", + } + TMDB_ADDON_CONFIG: str = ( + "N4IgDgTg9gbglgEwKYEkC2CBGKEgFwgAuEArkiADQgRKEkQB26WO+Rp5VANgIYMDmJHv3IEkDALQBVAMqUQAZ2JIeaOAPwBtALpUAxj0I8uUfgq27FKiHoAWAUQY9MXJLgIAzYws4gDSgGEoEgZCfABWKgVbKAB3AEERACVDdX4UBgBxcRpzAmIyeXFnV0SkFMI0ti8uH3louLKKtIB1OEJbZkxmjU9vcgBfIA" # noqa + ) + TMDB_ADDON_HOST: str = "https://94c8cb9f702d-tmdb-addon.baby-beamup.club" PORT: int = 8000 ADDON_ID: str = "com.bimal.watchly" + ADDON_NAME: str = "Watchly" + REDIS_URL: str = "redis://localhost:6379/0" + TOKEN_SALT: str = "change-me" + TOKEN_TTL_SECONDS: int = 0 # 0 = never expire + ANNOUNCEMENT_HTML: str = "" + AUTO_UPDATE_CATALOGS: bool = True + CATALOG_REFRESH_INTERVAL_SECONDS: int = 21600 # 6 hours APP_ENV: Literal["development", "production"] = "development" + @property + def TMDB_ADDON_URL(self) -> str: + return f"{self.TMDB_ADDON_HOST}/{self.TMDB_ADDON_CONFIG}" -settings = Settings() +settings = Settings() diff --git a/app/models/__init__.py b/app/models/__init__.py index f4d7507..72c00e9 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,3 +1,3 @@ -from .stremio import StremioMeta, StremioCatalogResponse +from .stremio import StremioCatalogResponse, StremioMeta __all__ = ["StremioMeta", "StremioCatalogResponse"] diff --git a/app/models/stremio.py b/app/models/stremio.py index 7d810b8..782c980 100644 --- a/app/models/stremio.py +++ b/app/models/stremio.py @@ -1,4 +1,3 @@ -from typing import List, Optional from pydantic import BaseModel @@ -8,20 +7,19 @@ class StremioMeta(BaseModel): id: str type: str name: str - poster: Optional[str] = None - posterShape: Optional[str] = None - background: Optional[str] = None - logo: Optional[str] = None - description: Optional[str] = None - releaseInfo: Optional[str] = None - year: Optional[str] = None - imdbRating: Optional[str] = None - genres: Optional[List[str]] = None - website: Optional[str] = None + poster: str | None = None + posterShape: str | None = None + background: str | None = None + logo: str | None = None + description: str | None = None + releaseInfo: str | None = None + year: str | None = None + imdbRating: str | None = None + genres: list[str] | None = None + website: str | None = None class StremioCatalogResponse(BaseModel): """Stremio catalog response format.""" - metas: List[StremioMeta] - + metas: list[StremioMeta] diff --git a/app/services/catalog.py b/app/services/catalog.py index 02ab8ca..2d504cd 100644 --- a/app/services/catalog.py +++ b/app/services/catalog.py @@ -1,10 +1,11 @@ +import asyncio +from collections import Counter + from app.services.stremio_service import StremioService from app.services.tmdb_service import TMDBService -import asyncio from .tmdb.genre import MOVIE_GENRE_TO_ID_MAP, SERIES_GENRE_TO_ID_MAP -from collections import Counter -from loguru import logger + class DynamicCatalogService: @@ -68,12 +69,8 @@ async def get_genre_based_catalogs(self, library_items: list[dict]): loved_series = loved_series[:5] # fetch details:: genre details from tmdb addon - movie_tasks = [ - self.tmdb_service.get_addon_meta("movie", item.get('_id').strip()) for item in loved_movies - ] - series_tasks = [ - self.tmdb_service.get_addon_meta("series", item.get('_id').strip()) for item in loved_series - ] + movie_tasks = [self.tmdb_service.get_addon_meta("movie", item.get("_id").strip()) for item in loved_movies] + series_tasks = [self.tmdb_service.get_addon_meta("series", item.get("_id").strip()) for item in loved_series] movie_details = await asyncio.gather(*movie_tasks) series_details = await asyncio.gather(*series_tasks) @@ -97,28 +94,25 @@ async def get_genre_based_catalogs(self, library_items: list[dict]): # convert id to name top_2_movie_genres = [str(MOVIE_GENRE_TO_ID_MAP[genre_name]) for genre_name in top_2_movie_genre_names] - top_2_series_genres = [ - str(SERIES_GENRE_TO_ID_MAP[genre_name]) for genre_name in top_2_series_genre_names - ] + top_2_series_genres = [str(SERIES_GENRE_TO_ID_MAP[genre_name]) for genre_name in top_2_series_genre_names] catalogs = [] - for idx, genre in enumerate(top_2_movie_genres): - catalogs.append( - { - "type": "movie", - "id": f"watchly.genre.{genre}", - "name": top_2_movie_genre_names[idx], - "extra": [], - } - ) - for idx, genre in enumerate(top_2_series_genres): - catalogs.append( - { - "type": "series", - "id": f"watchly.genre.{genre}", - "name": top_2_series_genre_names[idx], - "extra": [], - } - ) + catalogs.append( + { + "type": "movie", + "id": f"watchly.genre.{'_'.join(top_2_movie_genres)}", + "name": "You might also Like", + "extra": [], + } + ) + + catalogs.append( + { + "type": "series", + "id": f"watchly.genre.{'_'.join(top_2_series_genres)}", + "name": "You might also Like", + "extra": [], + } + ) return catalogs diff --git a/app/services/catalog_updater.py b/app/services/catalog_updater.py new file mode 100644 index 0000000..70ad5b0 --- /dev/null +++ b/app/services/catalog_updater.py @@ -0,0 +1,111 @@ +import asyncio +from typing import Any + +from loguru import logger + +from app.services.catalog import DynamicCatalogService +from app.services.stremio_service import StremioService +from app.services.token_store import token_store + +# Max number of concurrent updates to prevent overwhelming external APIs +MAX_CONCURRENT_UPDATES = 5 + + +async def refresh_catalogs_for_credentials(credentials: dict[str, Any], auth_key: str | None = None) -> bool: + """Regenerate catalogs for the provided credentials and push them to Stremio.""" + stremio_service = StremioService( + username=credentials.get("username") or "", + password=credentials.get("password") or "", + auth_key=auth_key or credentials.get("authKey"), + ) + try: + library_items = await stremio_service.get_library_items() + dynamic_catalog_service = DynamicCatalogService(stremio_service=stremio_service) + + catalogs = await dynamic_catalog_service.get_watched_loved_catalogs(library_items=library_items) + catalogs += await dynamic_catalog_service.get_genre_based_catalogs(library_items=library_items) + logger.info( + f"Prepared {len(catalogs)} catalogs for {credentials.get('authKey') or credentials.get('username')}" + ) + auth_key = await stremio_service.get_auth_key() + return await stremio_service.update_catalogs(catalogs, auth_key) + finally: + await stremio_service.close() + + +class BackgroundCatalogUpdater: + """Periodic job that refreshes catalogs for every stored credential token.""" + + def __init__(self, interval_seconds: int) -> None: + self.interval_seconds = max(60, interval_seconds) + self._task: asyncio.Task | None = None + self._stop_event = asyncio.Event() + + def start(self) -> None: + if self._task is not None: + return + self._stop_event.clear() + self._task = asyncio.create_task(self._run()) + + async def stop(self) -> None: + if self._task is None: + return + self._stop_event.set() + await self._task + self._task = None + + async def refresh_all_tokens(self) -> None: + """Refresh catalogs for all tokens concurrently with a semaphore.""" + tasks = [] + sem = asyncio.Semaphore(MAX_CONCURRENT_UPDATES) + + async def _update_safe(key: str, payload: dict[str, Any]) -> None: + if not self._has_credentials(payload): + logger.debug( + f"Skipping token {self._mask_key(key)} with incomplete credentials", + ) + return + + async with sem: + try: + updated = await refresh_catalogs_for_credentials(payload) + logger.info( + f"Background refresh for {self._mask_key(key)} completed (updated={updated})", + ) + except Exception as exc: + logger.error(f"Background refresh failed for {self._mask_key(key)}: {exc}", exc_info=True) + + try: + async for key, payload in token_store.iter_payloads(): + tasks.append(asyncio.create_task(_update_safe(key, payload))) + + if tasks: + logger.info(f"Starting background refresh for {len(tasks)} tokens...") + await asyncio.gather(*tasks) + logger.info(f"Completed background refresh for {len(tasks)} tokens.") + else: + logger.info("No tokens found to refresh.") + + except Exception as exc: + logger.error(f"Catalog refresh scan failed: {exc}", exc_info=True) + + async def _run(self) -> None: + logger.info(f"Background catalog updater started. Interval: {self.interval_seconds}s") + try: + while not self._stop_event.is_set(): + await self.refresh_all_tokens() + try: + await asyncio.wait_for(self._stop_event.wait(), timeout=self.interval_seconds) + except TimeoutError: + continue + finally: + logger.info("Background catalog updater stopped.") + + @staticmethod + def _has_credentials(payload: dict[str, Any]) -> bool: + return bool(payload.get("authKey") or (payload.get("username") and payload.get("password"))) + + @staticmethod + def _mask_key(key: str) -> str: + suffix = key.split(":")[-1] + return f"***{suffix[-6:]}" diff --git a/app/services/recommendation_service.py b/app/services/recommendation_service.py index 65eb943..fbad84b 100644 --- a/app/services/recommendation_service.py +++ b/app/services/recommendation_service.py @@ -1,19 +1,20 @@ import asyncio -from typing import List, Dict, Optional, Set, Tuple from urllib.parse import unquote + from loguru import logger -from app.services.tmdb_service import TMDBService + from app.services.stremio_service import StremioService +from app.services.tmdb_service import TMDBService -def _parse_identifier(identifier: str) -> Tuple[Optional[str], Optional[int]]: +def _parse_identifier(identifier: str) -> tuple[str | None, int | None]: """Parse Stremio identifier to extract IMDB ID and TMDB ID.""" if not identifier: return None, None decoded = unquote(identifier) - imdb_id: Optional[str] = None - tmdb_id: Optional[int] = None + imdb_id: str | None = None + tmdb_id: int | None = None for token in decoded.split(","): token = token.strip() @@ -44,18 +45,18 @@ class RecommendationService: 5. Return formatted recommendations """ - def __init__(self, stremio_service: Optional[StremioService] = None): + def __init__(self, stremio_service: StremioService | None = None): + if stremio_service is None: + raise ValueError("StremioService instance is required for personalized recommendations") self.tmdb_service = TMDBService() - self.stremio_service = stremio_service or StremioService() + self.stremio_service = stremio_service self.per_item_limit = 20 async def _fetch_catlogs_from_tmdb_addon(self, items: list[dict], media_type: str): final_results = [] media_type = "movie" if media_type == "movie" else "series" # now fetch addon meta for each recommendation - fetch_meta_tasks = [ - self.tmdb_service.get_addon_meta(media_type, f"tmdb:{item.get('id')}") for item in items - ] + fetch_meta_tasks = [self.tmdb_service.get_addon_meta(media_type, f"tmdb:{item.get('id')}") for item in items] addon_meta_results = await asyncio.gather(*fetch_meta_tasks, return_exceptions=True) for addon_meta in addon_meta_results: if isinstance(addon_meta, Exception): @@ -67,7 +68,7 @@ async def _fetch_catlogs_from_tmdb_addon(self, items: list[dict], media_type: st final_results.append(meta_data) return final_results - async def get_recommendations_for_item(self, item_id: str) -> List[Dict]: + async def get_recommendations_for_item(self, item_id: str) -> list[dict]: """ Get recommendations for a specific item by IMDB ID. @@ -95,7 +96,7 @@ async def get_recommendations_for_item(self, item_id: str) -> List[Dict]: logger.info(f"Found {len(recommendations)} recommendations for {item_id}") return await self._fetch_catlogs_from_tmdb_addon(recommendations, media_type) - async def _fetch_recommendations_from_tmdb(self, item_id: str, media_type: str, limit: int) -> List[Dict]: + async def _fetch_recommendations_from_tmdb(self, item_id: str, media_type: str, limit: int) -> list[dict]: """ Fetch recommendations from TMDB for a given TMDB ID. """ @@ -122,12 +123,12 @@ async def _fetch_recommendations_from_tmdb(self, item_id: str, media_type: str, async def get_recommendations( self, - content_type: Optional[str] = None, + content_type: str | None = None, source_items_limit: int = 2, recommendations_per_source: int = 5, max_results: int = 50, include_watched: bool = False, - ) -> List[Dict]: + ) -> list[dict]: """ Get recommendations based on user's Stremio library. @@ -190,8 +191,8 @@ async def get_recommendations( # Step 4: Build exclusion sets (IMDB IDs and TMDB IDs) for watched items # We don't want to recommend things the user has already watched - watched_imdb_ids: Set[str] = set() - watched_tmdb_ids: Set[int] = set() + watched_imdb_ids: set[str] = set() + watched_tmdb_ids: set[int] = set() for item in watched_items: imdb_id, tmdb_id = _parse_identifier(item.get("_id", "")) if imdb_id: @@ -205,7 +206,9 @@ async def get_recommendations( # Each source item will generate its own set of recommendations recommendation_tasks = [ self._fetch_recommendations_from_tmdb( - source_item.get("_id"), source_item.get("type"), recommendations_per_source + source_item.get("_id"), + source_item.get("type"), + recommendations_per_source, ) for source_item in source_items ] @@ -213,7 +216,7 @@ async def get_recommendations( # Step 6: Aggregate recommendations from all source items # Use dictionary to deduplicate by IMDB ID and combine scores - unique_recommendations: Dict[str, Dict] = {} # Key: IMDB ID, Value: Full recommendation data + unique_recommendations: dict[str, dict] = {} # Key: IMDB ID, Value: Full recommendation data flat_recommendations = [] for recommendation_batch in all_recommendation_results: @@ -257,7 +260,7 @@ async def get_recommendations( logger.info(f"Generated {len(sorted_recommendations)} unique recommendations") return sorted_recommendations - async def get_recommendations_for_genre(self, genre_id: str, media_type: str) -> List[Dict]: + async def get_recommendations_for_genre(self, genre_id: str, media_type: str) -> list[dict]: """ Get recommendations for a specific genre. """ diff --git a/app/services/stremio_service.py b/app/services/stremio_service.py index b637ae3..adb8ada 100644 --- a/app/services/stremio_service.py +++ b/app/services/stremio_service.py @@ -1,26 +1,34 @@ +import asyncio + import httpx -from typing import List, Dict, Optional from loguru import logger -from app.core.config import settings -import asyncio +from app.core.config import settings BASE_CATALOGS = [ {"type": "movie", "id": "watchly.rec", "name": "Recommended", "extra": []}, {"type": "series", "id": "watchly.rec", "name": "Recommended", "extra": []}, ] + + class StremioService: """Service for interacting with Stremio API to fetch user library.""" - def __init__(self, username: str = "", password: str = ""): + def __init__( + self, + username: str = "", + password: str = "", + auth_key: str | None = None, + ): self.base_url = "https://api.strem.io" self.username = username self.password = password - if not self.username or not self.password: - raise ValueError("Username and password are required") + self._auth_key: str | None = auth_key + if not self._auth_key and (not self.username or not self.password): + raise ValueError("Username/password or auth key are required") # Reuse HTTP client for connection pooling and better performance - self._client: Optional[httpx.AsyncClient] = None - self._likes_client: Optional[httpx.AsyncClient] = None + self._client: httpx.AsyncClient | None = None + self._likes_client: httpx.AsyncClient | None = None async def _get_client(self) -> httpx.AsyncClient: """Get or create the main Stremio API client.""" @@ -49,8 +57,10 @@ async def close(self): await self._likes_client.aclose() self._likes_client = None - async def _get_auth_token(self) -> str: - """Get authentication token from Stremio.""" + async def _login_for_auth_key(self) -> str: + """Login with username/password and fetch a fresh auth key.""" + if not self.username or not self.password: + raise ValueError("Username and password are required to fetch an auth key") url = f"{self.base_url}/api/login" payload = { "email": self.username, @@ -63,16 +73,34 @@ async def _get_auth_token(self) -> str: client = await self._get_client() result = await client.post(url, json=payload) result.raise_for_status() - auth_key = result.json().get("result", {}).get("authKey", "") + data = result.json() + auth_key = data.get("result", {}).get("authKey", "") if auth_key: logger.info("Successfully authenticated with Stremio") + self._auth_key = auth_key else: - logger.warning("Stremio authentication returned empty auth key") + error_obj = data.get("error") or data + error_message = "Invalid Stremio username/password." + if isinstance(error_obj, dict): + error_message = error_obj.get("message") or error_message + elif isinstance(error_obj, str): + error_message = error_obj or error_message + logger.warning(error_obj) + raise ValueError(f"Stremio: {error_message}") return auth_key except Exception as e: logger.error(f"Error authenticating with Stremio: {e}", exc_info=True) raise + async def get_auth_key(self) -> str: + """Return a cached auth key or login to retrieve one.""" + if self._auth_key: + return self._auth_key + auth_key = await self._login_for_auth_key() + if not auth_key: + raise ValueError("Failed to obtain Stremio auth key") + return auth_key + async def is_loved(self, auth_key: str, imdb_id: str, media_type: str) -> bool: """Check if user has loved a movie or series.""" if not imdb_id.startswith("tt"): @@ -100,18 +128,18 @@ async def is_loved(self, auth_key: str, imdb_id: str, media_type: str) -> bool: ) return False - async def get_library_items(self) -> Dict[str, List[Dict]]: + async def get_library_items(self) -> dict[str, list[dict]]: """ Fetch library items from Stremio once and return both watched and loved items. Returns a dict with 'watched' and 'loved' keys. """ - if not self.username or not self.password: + if not self._auth_key and (not self.username or not self.password): logger.warning("Stremio credentials not configured") return {"watched": [], "loved": []} try: # Get auth token - auth_key = await self._get_auth_token() + auth_key = await self.get_auth_key() if not auth_key: logger.error("Failed to get Stremio auth token") return {"watched": [], "loved": []} @@ -144,16 +172,11 @@ async def get_library_items(self) -> Dict[str, List[Dict]]: # Check if user has loved the movie or series in parallel loved_statuses = await asyncio.gather( - *[ - self.is_loved(auth_key, item.get("_id"), item.get("type")) - for item in watched_items - ] + *[self.is_loved(auth_key, item.get("_id"), item.get("type")) for item in watched_items] ) # Separate loved and watched items - loved_items = [ - item for item, loved in zip(watched_items, loved_statuses) if loved - ] + loved_items = [item for item, loved in zip(watched_items, loved_statuses) if loved] logger.info(f"Found {len(loved_items)} loved library items") # Format watched items @@ -193,32 +216,46 @@ async def get_addons(self, auth_key: str | None = None) -> list[dict]: url = f"{self.base_url}/api/addonCollectionGet" payload = { "type": "AddonCollectionGet", - "authKey": auth_key or await self._get_auth_token(), + "authKey": auth_key or await self.get_auth_key(), "update": True, } client = await self._get_client() result = await client.post(url, json=payload) result.raise_for_status() - logger.info(f"Found {len(result.json().get('result', {}).get('addons', []))} addons") - return result.json().get("result", {}).get("addons", []) + data = result.json() + error_payload = data.get("error") + if not error_payload and (data.get("code") and data.get("message")): + error_payload = data + + if error_payload: + message = "Invalid Stremio auth key." + if isinstance(error_payload, dict): + message = error_payload.get("message") or message + elif isinstance(error_payload, str): + message = error_payload or message + logger.warning("Addon collection request failed: {}", error_payload) + raise ValueError(f"Stremio: {message}") + addons = data.get("result", {}).get("addons", []) + logger.info(f"Found {len(addons)} addons") + return addons async def update_addon(self, addons: list[dict], auth_key: str | None = None): """Update an addon in Stremio.""" url = f"{self.base_url}/api/addonCollectionSet" payload = { "type": "AddonCollectionSet", - "authKey": auth_key or await self._get_auth_token(), + "authKey": auth_key or await self.get_auth_key(), "addons": addons, } client = await self._get_client() result = await client.post(url, json=payload) result.raise_for_status() - logger.info(f"Updated addons") + logger.info("Updated addons") return result.json().get("result", {}).get("success", False) async def update_catalogs(self, catalogs: list[dict], auth_key: str | None = None): - auth_key = auth_key or await self._get_auth_token() + auth_key = auth_key or await self.get_auth_key() addons = await self.get_addons(auth_key) catalogs = BASE_CATALOGS + catalogs logger.info(f"Found {len(addons)} addons") diff --git a/app/services/tmdb_service.py b/app/services/tmdb_service.py index 756b2b3..4de6c7b 100644 --- a/app/services/tmdb_service.py +++ b/app/services/tmdb_service.py @@ -1,6 +1,6 @@ import httpx -from typing import Dict, Optional, Tuple from loguru import logger + from app.core.config import settings from app.utils import cached_api_call @@ -11,10 +11,12 @@ class TMDBService: def __init__(self): self.api_key = settings.TMDB_API_KEY self.base_url = "https://api.themoviedb.org/3" - self.addon_url = "https://94c8cb9f702d-tmdb-addon.baby-beamup.club/N4IgTgDgJgRg1gUwJ4gFwgC4AYC0AzMBBHSWEAGhAjAHsA3ASygQEkBbWFqNTMAVwQVwCDHzAA7dp27oM-QZQA2AQ3EBzPsrWD0CcTgCqAZSEBnOQmVsG6tAG0AupQDGyjMsU01p+05CnLMGcACwBRcWUYRQQZEDwPAKFXcwBhGj5xDDQAVkpTYJoAdwBBbQAlNxs1FnEAcT1CH1l5IT1I6NKECowqnjkBMwKS8sr1AHUGDGCpGG7e9HjFRIBfIA" # noqa + self.addon_url = settings.TMDB_ADDON_URL # Reuse HTTP client for connection pooling and better performance - self._client: Optional[httpx.AsyncClient] = None - self._addon_client: Optional[httpx.AsyncClient] = None + self._client: httpx.AsyncClient | None = None + self._addon_client: httpx.AsyncClient | None = None + if not self.api_key: + logger.warning("TMDB_API_KEY is not configured. Catalog endpoints will fail until the key is provided.") async def _get_client(self) -> httpx.AsyncClient: """Get or create the main TMDB API client.""" @@ -44,7 +46,7 @@ async def close(self): self._addon_client = None @cached_api_call - async def get_addon_meta(self, type: str, id: str) -> Dict: + async def get_addon_meta(self, type: str, id: str) -> dict: """Get addon metadata for a specific type and ID.""" url = f"{self.addon_url}/meta/{type}/{id}.json" client = await self._get_addon_client() @@ -52,8 +54,10 @@ async def get_addon_meta(self, type: str, id: str) -> Dict: response.raise_for_status() return response.json() - async def _make_request(self, endpoint: str, params: Optional[Dict] = None) -> Dict: + async def _make_request(self, endpoint: str, params: dict | None = None) -> dict: """Make a request to the TMDB API.""" + if not self.api_key: + raise RuntimeError("TMDB_API_KEY is not configured. Set the environment variable to enable TMDB requests.") url = f"{self.base_url}{endpoint}" default_params = {"api_key": self.api_key, "language": "en-US"} @@ -73,23 +77,17 @@ async def _make_request(self, endpoint: str, params: Optional[Dict] = None) -> D try: return response.json() except ValueError as e: - logger.error( - f"TMDB API returned invalid JSON for {endpoint}: {e}. Response: {response.text[:200]}" - ) + logger.error(f"TMDB API returned invalid JSON for {endpoint}: {e}. Response: {response.text[:200]}") return {} except httpx.HTTPStatusError as e: - logger.error( - f"TMDB API error for {endpoint}: {e.response.status_code} - {e.response.text[:200]}" - ) + logger.error(f"TMDB API error for {endpoint}: {e.response.status_code} - {e.response.text[:200]}") raise except httpx.RequestError as e: logger.error(f"TMDB API request error for {endpoint}: {e}") raise @cached_api_call - async def find_by_imdb_id( - self, imdb_id: str - ) -> Tuple[Optional[int], Optional[str]]: + async def find_by_imdb_id(self, imdb_id: str) -> tuple[int | None, str | None]: """Find TMDB ID and type by IMDB ID.""" try: endpoint = f"/find/{imdb_id}" @@ -130,35 +128,33 @@ async def find_by_imdb_id( return None, None @cached_api_call - async def get_movie_details(self, movie_id: int) -> Dict: + async def get_movie_details(self, movie_id: int) -> dict: """Get details of a specific movie with credits and external IDs.""" params = {"append_to_response": "credits,external_ids"} return await self._make_request(f"/movie/{movie_id}", params=params) @cached_api_call - async def get_tv_details(self, tv_id: int) -> Dict: + async def get_tv_details(self, tv_id: int) -> dict: """Get details of a specific TV series with credits and external IDs.""" params = {"append_to_response": "credits,external_ids"} return await self._make_request(f"/tv/{tv_id}", params=params) @cached_api_call - async def get_recommendations( - self, tmdb_id: int, media_type: str, page: int = 1 - ) -> Dict: + async def get_recommendations(self, tmdb_id: int, media_type: str, page: int = 1) -> dict: """Get recommendations based on TMDB ID and media type.""" params = {"page": page} endpoint = f"/{media_type}/{tmdb_id}/recommendations" return await self._make_request(endpoint, params=params) @cached_api_call - async def get_similar(self, tmdb_id: int, media_type: str, page: int = 1) -> Dict: + async def get_similar(self, tmdb_id: int, media_type: str, page: int = 1) -> dict: """Get similar content based on TMDB ID and media type.""" params = {"page": page} endpoint = f"/{media_type}/{tmdb_id}/similar" return await self._make_request(endpoint, params=params) @cached_api_call - async def get_discover(self, media_type: str, params: dict[str, str]) -> Dict: + async def get_discover(self, media_type: str, params: dict[str, str]) -> dict: """Get discover content based on params.""" media_type = "movie" if media_type == "movie" else "tv" endpoint = f"/discover/{media_type}" diff --git a/app/services/token_store.py b/app/services/token_store.py new file mode 100644 index 0000000..c0b0274 --- /dev/null +++ b/app/services/token_store.py @@ -0,0 +1,161 @@ +import base64 +import hashlib +import hmac +import json +from collections.abc import AsyncIterator +from typing import Any + +import redis.asyncio as redis +from cryptography.fernet import Fernet, InvalidToken +from loguru import logger + +from app.core.config import settings + + +class TokenStore: + """Redis-backed store for user credentials and auth tokens.""" + + KEY_PREFIX = "watchly:token:" + + def __init__(self) -> None: + self._client: redis.Redis | None = None + self._cipher: Fernet | None = None + + if not settings.REDIS_URL: + logger.warning("REDIS_URL is not set. Token storage will fail until a Redis instance is configured.") + if not settings.TOKEN_SALT or settings.TOKEN_SALT == "change-me": + logger.warning( + "TOKEN_SALT is missing or using the default placeholder. Set a strong value to secure tokens." + ) + + def _ensure_secure_salt(self) -> None: + if not settings.TOKEN_SALT or settings.TOKEN_SALT == "change-me": + logger.error("Refusing to store credentials because TOKEN_SALT is unset or using the insecure default.") + raise RuntimeError( + "Server misconfiguration: TOKEN_SALT must be set to a non-default value before storing credentials." + ) + + def _get_cipher(self) -> Fernet: + """Get or create Fernet cipher instance based on TOKEN_SALT.""" + if self._cipher is None: + # Derive a 32-byte key from TOKEN_SALT using SHA256, then URL-safe base64 encode it + # This ensures we always have a valid Fernet key regardless of the salt's format + key_bytes = hashlib.sha256(settings.TOKEN_SALT.encode()).digest() + fernet_key = base64.urlsafe_b64encode(key_bytes) + self._cipher = Fernet(fernet_key) + return self._cipher + + async def _get_client(self) -> redis.Redis: + if self._client is None: + self._client = redis.from_url(settings.REDIS_URL, decode_responses=True, encoding="utf-8") + return self._client + + def _hash_token(self, token: str) -> str: + secret = settings.TOKEN_SALT.encode("utf-8") + return hmac.new(secret, msg=token.encode("utf-8"), digestmod=hashlib.sha256).hexdigest() + + def _format_key(self, hashed_token: str) -> str: + return f"{self.KEY_PREFIX}{hashed_token}" + + def _normalize_payload(self, payload: dict[str, Any]) -> dict[str, Any]: + return { + "username": (payload.get("username") or "").strip() or None, + "password": payload.get("password") or None, + "authKey": (payload.get("authKey") or "").strip() or None, + "includeWatched": bool(payload.get("includeWatched", False)), + } + + def _derive_token_value(self, payload: dict[str, Any]) -> str: + canonical = { + "username": payload.get("username") or "", + "password": payload.get("password") or "", + "authKey": payload.get("authKey") or "", + "includeWatched": bool(payload.get("includeWatched", False)), + } + serialized = json.dumps(canonical, sort_keys=True, separators=(",", ":")) + secret = settings.TOKEN_SALT.encode("utf-8") + return hmac.new(secret, serialized.encode("utf-8"), hashlib.sha256).hexdigest() + + async def store_payload(self, payload: dict[str, Any]) -> tuple[str, bool]: + self._ensure_secure_salt() + normalized = self._normalize_payload(payload) + token = self._derive_token_value(normalized) + hashed = self._hash_token(token) + key = self._format_key(hashed) + + # JSON Encode -> Encrypt -> Store + json_str = json.dumps(normalized) + encrypted_value = self._get_cipher().encrypt(json_str.encode()).decode("utf-8") + + client = await self._get_client() + existing = await client.exists(key) + + if settings.TOKEN_TTL_SECONDS and settings.TOKEN_TTL_SECONDS > 0: + await client.setex(key, settings.TOKEN_TTL_SECONDS, encrypted_value) + logger.info( + "Stored encrypted credential payload with TTL %s seconds", + settings.TOKEN_TTL_SECONDS, + ) + else: + await client.set(key, encrypted_value) + logger.info("Stored encrypted credential payload without expiration") + return token, not bool(existing) + + async def get_payload(self, token: str) -> dict[str, Any] | None: + hashed = self._hash_token(token) + key = self._format_key(hashed) + client = await self._get_client() + encrypted_raw = await client.get(key) + + if encrypted_raw is None: + return None + + try: + # Decrypt -> JSON Decode + decrypted_json = self._get_cipher().decrypt(encrypted_raw.encode()).decode("utf-8") + return json.loads(decrypted_json) + except (InvalidToken, json.JSONDecodeError, UnicodeDecodeError): + logger.warning("Failed to decrypt or decode cached payload for token. Key might have changed.") + return None + + async def delete_token(self, token: str) -> None: + hashed = self._hash_token(token) + key = self._format_key(hashed) + client = await self._get_client() + await client.delete(key) + + async def iter_payloads(self) -> AsyncIterator[tuple[str, dict[str, Any]]]: + """Iterate over all stored payloads, yielding key and payload.""" + try: + client = await self._get_client() + except (redis.RedisError, OSError) as exc: + logger.warning("Skipping credential iteration; Redis unavailable: %s", exc) + return + + pattern = f"{self.KEY_PREFIX}*" + cipher = self._get_cipher() + + try: + async for key in client.scan_iter(match=pattern): + try: + encrypted_raw = await client.get(key) + except (redis.RedisError, OSError) as exc: + logger.warning("Failed to fetch payload for %s: %s", key, exc) + continue + + if encrypted_raw is None: + continue + + try: + decrypted_json = cipher.decrypt(encrypted_raw.encode()).decode("utf-8") + payload = json.loads(decrypted_json) + except (InvalidToken, json.JSONDecodeError, UnicodeDecodeError): + logger.warning("Failed to decrypt payload for key %s. Skipping.", key) + continue + + yield key, payload + except (redis.RedisError, OSError) as exc: + logger.warning("Failed to scan credential tokens: %s", exc) + + +token_store = TokenStore() diff --git a/app/utils.py b/app/utils.py index 99a3ec4..271b61d 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,14 +1,16 @@ """Utility functions for caching and other helpers.""" -import base64 -import binascii import hashlib import json +from collections.abc import Callable from functools import wraps -from typing import Callable, Dict, Optional +from typing import Any + from cachetools import TTLCache -from loguru import logger from fastapi import HTTPException +from loguru import logger + +from app.services.token_store import token_store # Cache with 1 day TTL (86400 seconds) CACHE_TTL = 86400 @@ -95,42 +97,35 @@ def clear_cache(): logger.info("All caches cleared") -def decode_credentials(encoded: str) -> Dict[str, any]: - """ - Decode base64 encoded credentials and configuration. - - Args: - encoded: Base64 encoded JSON string containing username, password, and optional config - - Returns: - Dictionary with 'username', 'password', and 'includeWatched' keys - - Raises: - HTTPException: If decoding fails - """ - try: - decoded_bytes = base64.b64decode(encoded) - config = json.loads(decoded_bytes.decode('utf-8')) - - if not isinstance(config, dict): - raise ValueError("Config must be a dictionary") - - username = config.get('username') - password = config.get('password') +async def resolve_user_credentials(token: str) -> dict[str, Any]: + """Resolve credentials from Redis token.""" + if not token: + raise HTTPException( + status_code=400, + detail="Missing credentials token. Please reinstall the addon.", + ) - if not username or not password: - raise ValueError("Username and password are required") + payload = await token_store.get_payload(token) + if not payload: + raise HTTPException( + status_code=401, + detail="Invalid or expired token. Please reconfigure the addon.", + ) - # Get includeWatched config, default to False (only loved items) - include_watched = config.get('includeWatched', False) + include_watched = payload.get("includeWatched", False) + username = payload.get("username") + password = payload.get("password") + auth_key = payload.get("authKey") - return { - 'username': username, - 'password': password, - 'includeWatched': include_watched - } - except (binascii.Error, json.JSONDecodeError, ValueError) as e: - logger.error(f"Failed to decode credentials: {e}") + if not auth_key and (not username or not password): raise HTTPException( - status_code=400, detail="Invalid credentials encoding. Please reconfigure your addon." + status_code=400, + detail="Stored token is missing credentials. Please reconfigure the addon.", ) + + return { + "username": username, + "password": password, + "authKey": auth_key, + "includeWatched": include_watched, + } diff --git a/docker-compose.yml b/docker-compose.yml index eb2f889..7b60315 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,12 +11,26 @@ services: - TMDB_API_KEY=${TMDB_API_KEY} - PORT=${PORT:-8000} - ADDON_ID=${ADDON_ID:-com.bimal.watchly} + - ADDON_NAME=${ADDON_NAME:-Watchly} + - REDIS_URL=${REDIS_URL:-redis://redis:6379/0} + - TOKEN_SALT=${TOKEN_SALT:-change-me} + - TOKEN_TTL_SECONDS=${TOKEN_TTL_SECONDS:-0} + - ANNOUNCEMENT_HTML=${ANNOUNCEMENT_HTML:-} env_file: - .env volumes: - ./static:/app/static:ro networks: - watchly-network + depends_on: + - redis + + redis: + image: redis:7-alpine + container_name: watchly-redis + restart: unless-stopped + networks: + - watchly-network networks: watchly-network: diff --git a/main.py b/main.py index 224d47d..4bc425b 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,8 @@ import os + import uvicorn -from app.core import app, settings + +from app.core.config import settings if __name__ == "__main__": PORT = os.getenv("PORT", settings.PORT) diff --git a/procfile b/procfile index 747afab..5dcb16d 100644 --- a/procfile +++ b/procfile @@ -1 +1 @@ -web: uvicorn app.core.app:app --host=0.0.0.0 --port=${PORT} \ No newline at end of file +web: uvicorn app.core.app:app --host=0.0.0.0 --port=${PORT} diff --git a/pyproject.toml b/pyproject.toml index 1a4f3fe..5b69827 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,11 +6,13 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "cachetools>=6.2.2", + "cryptography>=46.0.3", "fastapi>=0.104.1", "httpx>=0.25.2", "loguru>=0.7.2", "pydantic>=2.5.0", "pydantic-settings>=2.1.0", + "redis>=5.0.1", "uvicorn[standard]>=0.24.0", ] @@ -18,4 +20,17 @@ dependencies = [ dev = [ "black>=25.11.0", "flake9>=3.8.3.post2", + "pre-commit>=4.4.0", ] + + +# ==== black ==== +[tool.black] +line-length = 119 +target-version = ['py311'] + + +# ==== isort ==== +[tool.isort] +profile = "black" +line_length = 119 diff --git a/requirements-dev.txt b/requirements-dev.txt index e1445bf..b37f676 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ -r requirements.txt black>=25.1.0 isort>=6.0.0 -flake8>=7.0.0 \ No newline at end of file +flake8>=7.0.0 diff --git a/requirements.txt b/requirements.txt index 7ac0dc8..fe9a36f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,6 @@ httpx>=0.25.2 pydantic>=2.5.0 pydantic-settings>=2.1.0 loguru>=0.7.2 -cachetools>=6.2.2 \ No newline at end of file +cachetools>=6.2.2 +redis>=5.0.1 +cryptography>=41.0.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..f3903ce --- /dev/null +++ b/setup.cfg @@ -0,0 +1,11 @@ +# flake8 and pycodestyle don't support pyproject.toml +# https://github.com/PyCQA/flake8/issues/234 +# https://github.com/PyCQA/pycodestyle/issues/813 +[flake8] +max-line-length = 119 +exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv,.venv +per-file-ignores = __init__.py:F401 + +[pycodestyle] +max-line-length = 119 +exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv,.venv diff --git a/static/index.html b/static/index.html index c267347..5872545 100644 --- a/static/index.html +++ b/static/index.html @@ -4,105 +4,185 @@ - Watchly - Stremio Addon Configuration + Watchly - Personalized Stremio Recommendations + -
-
-
+
+ +
+

+ Your Stremio,
+ Reimagined. +

+

+ Watchly analyzes your library to deliver personalized movie and TV show recommendations, + powered by TMDB's advanced discovery engine. +

+ +
+
+
+ +
+ Based on loved content +
+
+
+ +
+ Hides watched items +
+
+
+ +
+ Instant updates +
+
+
+ +
+ Secure & Private +
+
+
+ + +
+
-

Watchly

-

Stremio Addon Configuration

+

Configure Watchly

+
-
+
- - + +
+ +
-
- - + +
+
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ + +
+
+
+ + +
- -
-
- +
- - @@ -110,11 +190,11 @@

How to use:

- \ No newline at end of file + diff --git a/static/script.js b/static/script.js index 92388fd..d6cc278 100644 --- a/static/script.js +++ b/static/script.js @@ -2,18 +2,25 @@ document.addEventListener('DOMContentLoaded', function () { const form = document.getElementById('configForm'); const usernameInput = document.getElementById('username'); const passwordInput = document.getElementById('password'); + const authKeyInput = document.getElementById('authKey'); + const authMethodSelect = document.getElementById('authMethod'); + const credentialsFields = document.getElementById('credentialsFields'); + const authKeyFieldWrapper = document.getElementById('authKeyField'); const submitBtn = document.getElementById('submitBtn'); const errorMessage = document.getElementById('errorMessage'); const successMessage = document.getElementById('successMessage'); - const addonUrlInput = document.getElementById('addonUrl'); + const addonUrlBox = document.getElementById('addonUrl'); const copyBtn = document.getElementById('copyBtn'); const installDesktopBtn = document.getElementById('installDesktopBtn'); const installWebBtn = document.getElementById('installWebBtn'); const resetBtn = document.getElementById('resetBtn'); const btnText = submitBtn.querySelector('.btn-text'); - const btnLoader = submitBtn.querySelector('.btn-loader'); + const btnLoader = submitBtn.querySelector('.loader'); + const toggleButtons = document.querySelectorAll('.toggle-btn'); + + // Store the raw URL string since div doesn't have .value + let generatedUrl = ''; - // Helper functions function showError(message) { errorMessage.textContent = message; errorMessage.style.display = 'block'; @@ -24,168 +31,161 @@ document.addEventListener('DOMContentLoaded', function () { } function setLoading(loading) { + submitBtn.disabled = loading; if (loading) { - submitBtn.disabled = true; - btnText.style.display = 'none'; - btnLoader.style.display = 'inline'; + btnText.classList.add('hidden'); + btnLoader.classList.remove('hidden'); } else { - submitBtn.disabled = false; - btnText.style.display = 'inline'; - btnLoader.style.display = 'none'; + btnText.classList.remove('hidden'); + btnLoader.classList.add('hidden'); } } - // Check if there's an encoded value in the URL path - function checkForEncodedCredentials() { - const path = window.location.pathname; - // Check if path matches /{encoded}/configure - const match = path.match(/^\/(.+)\/configure$/); - if (match && match[1]) { - const encoded = match[1]; - try { - // Decode the credentials and config - const decoded = atob(encoded); - const config = JSON.parse(decoded); - - if (config.username && config.password) { - // Populate the form fields - usernameInput.value = config.username; - passwordInput.value = config.password; - // Set recommendation source if available - if (config.includeWatched !== undefined) { - const sourceValue = config.includeWatched ? 'watched' : 'loved'; - const radio = document.querySelector(`input[name="recommendationSource"][value="${sourceValue}"]`); - if (radio) { - radio.checked = true; - // Update visual state for browsers without :has() support - radio.closest('.radio-label')?.classList.add('checked'); - } - } - // Optionally show a message that fields were pre-filled - console.log('Credentials loaded from URL'); - } - } catch (error) { - // Invalid encoding, ignore and show error - console.error('Failed to decode credentials from URL:', error); - showError('Invalid credentials in URL. Please enter your credentials manually.'); - } + function updateMethodFields() { + const method = authMethodSelect.value; + if (method === 'credentials') { + credentialsFields.classList.remove('hidden'); + authKeyFieldWrapper.classList.add('hidden'); + usernameInput.required = true; + passwordInput.required = true; + authKeyInput.required = false; + } else { + credentialsFields.classList.add('hidden'); + authKeyFieldWrapper.classList.remove('hidden'); + usernameInput.required = false; + passwordInput.required = false; + authKeyInput.required = true; } } - // Check for encoded credentials on page load - checkForEncodedCredentials(); + authMethodSelect.addEventListener('change', () => { + updateMethodFields(); + hideError(); + }); - // Add visual feedback for radio buttons - const radioInputs = document.querySelectorAll('input[name="recommendationSource"]'); - radioInputs.forEach(radio => { - radio.addEventListener('change', function() { - // Remove checked class from all labels - document.querySelectorAll('.radio-label').forEach(label => { - label.classList.remove('checked'); - }); - // Add checked class to selected label - if (this.checked) { - this.closest('.radio-label')?.classList.add('checked'); + // Password/AuthKey Visibility Toggles + toggleButtons.forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const targetId = btn.dataset.target; + const input = document.getElementById(targetId); + if (input) { + const isPassword = input.type === 'password'; + input.type = isPassword ? 'text' : 'password'; + btn.textContent = isPassword ? 'Hide' : 'Show'; } }); - // Set initial state - if (radio.checked) { - radio.closest('.radio-label')?.classList.add('checked'); - } }); + // Help Alert for Auth Key + const showAuthHelp = document.getElementById('showAuthHelp'); + if (showAuthHelp) { + showAuthHelp.addEventListener('click', (e) => { + e.preventDefault(); + alert('To find your Auth Key:\n1. Go to web.strem.io\n2. Open Console (F12)\n3. Type: JSON.parse(localStorage.getItem("profile")).auth.key\n4. Copy the result (without quotes)'); + }); + } + form.addEventListener('submit', async function (e) { e.preventDefault(); + hideError(); + const method = authMethodSelect.value; const username = usernameInput.value.trim(); const password = passwordInput.value; - const recommendationSource = document.querySelector('input[name="recommendationSource"]:checked').value; - - if (!username || !password) { - showError('Please fill in all fields'); + const authKey = authKeyInput.value.trim(); + const includeWatched = document.querySelector('input[name="recommendationSource"]:checked').value === 'watched'; + + // Client-side validation + if (method === 'credentials') { + if (!username || !password) { + showError('Please enter both email and password.'); + return; + } + } else if (!authKey) { + showError('Please provide your Stremio Auth Key.'); return; } - // Hide error, show loading - hideError(); setLoading(true); try { - // Encode credentials and config - const config = { - username: username, - password: password, - includeWatched: recommendationSource === 'watched' - }; - - const encoded = btoa(JSON.stringify(config)); - - // Get current origin - const baseUrl = window.location.origin; - const addonUrl = `${baseUrl}/${encoded}/manifest.json`; - - // Show success - addonUrlInput.value = addonUrl; - form.style.display = 'none'; + const response = await fetch('/tokens/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: method === 'credentials' ? username : null, + password: method === 'credentials' ? password : null, + authKey: method === 'authkey' ? authKey : null, + includeWatched + }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: 'Failed to create token.' })); + throw new Error(errorData.detail || 'Failed to connect. Check credentials.'); + } + + const data = await response.json(); + generatedUrl = data.manifestUrl; + addonUrlBox.textContent = generatedUrl; + + form.classList.add('hidden'); successMessage.style.display = 'block'; } catch (error) { - showError('An error occurred. Please try again.'); console.error('Error:', error); + showError(error.message); } finally { setLoading(false); } }); - // Install on Stremio Desktop/Mobile installDesktopBtn.addEventListener('click', function () { - const addonUrl = addonUrlInput.value; - const stremioUrl = `stremio://${addonUrl.replace(/^https?:\/\//, '')}`; + if (!generatedUrl) return; + const stremioUrl = `stremio://${generatedUrl.replace(/^https?:\/\//, '')}`; window.location.href = stremioUrl; }); - // Install on Stremio Web installWebBtn.addEventListener('click', function () { - const addonUrl = encodeURIComponent(addonUrlInput.value); - // Open Stremio web app with addon installation - const stremioWebUrl = `https://web.stremio.com/#/addons?addon=${addonUrl}`; - window.open(stremioWebUrl, '_blank'); + if (!generatedUrl) return; + const stremioUrl = `https://web.stremio.com/#/addons?addon=${encodeURIComponent(generatedUrl)}`; + window.open(stremioUrl, '_blank'); }); - // Copy URL to clipboard - copyBtn.addEventListener('click', function () { - addonUrlInput.select(); - addonUrlInput.setSelectionRange(0, 99999); // For mobile devices + copyBtn.addEventListener('click', async function () { + if (!generatedUrl) return; try { - navigator.clipboard.writeText(addonUrlInput.value).then(function () { - copyBtn.textContent = '✓ Copied!'; - copyBtn.classList.add('copied'); - - setTimeout(function () { - copyBtn.textContent = '📋 Copy URL'; - copyBtn.classList.remove('copied'); - }, 2000); - }); - } catch (err) { - // Fallback for older browsers - document.execCommand('copy'); - copyBtn.textContent = '✓ Copied!'; - copyBtn.classList.add('copied'); - - setTimeout(function () { - copyBtn.textContent = '📋 Copy URL'; - copyBtn.classList.remove('copied'); + await navigator.clipboard.writeText(generatedUrl); + const originalText = copyBtn.textContent; + copyBtn.textContent = 'Copied!'; + copyBtn.classList.add('btn-primary'); + copyBtn.classList.remove('btn-outline'); + + setTimeout(() => { + copyBtn.textContent = originalText; + copyBtn.classList.remove('btn-primary'); + copyBtn.classList.add('btn-outline'); }, 2000); + } catch (err) { + console.error('Failed to copy:', err); + showError('Failed to copy to clipboard'); } }); resetBtn.addEventListener('click', function () { form.reset(); - form.style.display = 'block'; + authMethodSelect.value = 'credentials'; + updateMethodFields(); + + form.classList.remove('hidden'); successMessage.style.display = 'none'; hideError(); - usernameInput.focus(); + generatedUrl = ''; + addonUrlBox.textContent = ''; }); -}); + // Initialize + updateMethodFields(); +}); diff --git a/static/style.css b/static/style.css index 7ca50de..a085bbb 100644 --- a/static/style.css +++ b/static/style.css @@ -1,414 +1,488 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + +:root { + --primary: #0ea5e9; + --primary-hover: #0284c7; + --primary-light: rgba(14, 165, 233, 0.15); + --primary-glow: rgba(14, 165, 233, 0.4); + --bg-dark: #020617; + --bg-card: rgba(15, 23, 42, 0.6); + --text-main: #f8fafc; + --text-muted: #94a3b8; + --border: rgba(148, 163, 184, 0.1); + --gradient-start: #3b82f6; + --gradient-end: #06b6d4; + --success: #10b981; + --error: #ef4444; + --warning: #f59e0b; +} + * { margin: 0; padding: 0; box-sizing: border-box; } -:root { - --primary-color: #3b82f6; - --primary-hover: #2563eb; - --secondary-color: #60a5fa; - --background: #0f172a; - --surface: #1e293b; - --surface-light: #334155; - --text-primary: #f1f5f9; - --text-secondary: #cbd5e1; - --error: #ef4444; - --success: #10b981; - --border: #475569; - --shadow: rgba(0, 0, 0, 0.3); -} - body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - background: linear-gradient(135deg, #0f172a 0%, #1e293b 30%, #1e3a5f 60%, #1e293b 100%); - color: var(--text-primary); + font-family: 'Inter', sans-serif; + background-color: var(--bg-dark); + background-image: + radial-gradient(circle at 0% 0%, rgba(59, 130, 246, 0.2) 0%, transparent 50%), + radial-gradient(circle at 100% 100%, rgba(6, 182, 212, 0.2) 0%, transparent 50%); + color: var(--text-main); min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; - padding: 20px; line-height: 1.6; } -.container { +.wrapper { width: 100%; - max-width: 500px; - margin: 0 auto; + max-width: 1200px; + padding: 2rem; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4rem; + align-items: center; } -.card { - background: linear-gradient(135deg, var(--surface) 0%, rgba(30, 41, 59, 0.95) 100%); - border-radius: 16px; - padding: 40px; - box-shadow: 0 20px 60px var(--shadow), 0 0 0 1px rgba(59, 130, 246, 0.1); - border: 1px solid var(--border); - backdrop-filter: blur(10px); +@media (max-width: 968px) { + .wrapper { + grid-template-columns: 1fr; + gap: 2rem; + padding: 1rem; + } } -.header { - text-align: center; - margin-bottom: 32px; +/* Hero Section */ +.hero { + padding-right: 2rem; } -.logo { - width: 80px; - height: 80px; - margin-bottom: 16px; - object-fit: contain; - display: block; - margin-left: auto; - margin-right: auto; +.hero h1 { + font-size: 4.5rem; + font-weight: 800; + line-height: 1.1; + margin-bottom: 1.5rem; + background: linear-gradient(135deg, #fff 0%, #94a3b8 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + letter-spacing: -0.02em; } -.header h1 { - font-size: 2.5rem; - margin-bottom: 8px; - background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); +.hero h1 .gradient-text { + background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; - background-clip: text; + filter: drop-shadow(0 0 20px rgba(59, 130, 246, 0.3)); } -.subtitle { - color: var(--text-secondary); - font-size: 1rem; +.hero p { + font-size: 1.25rem; + color: var(--text-muted); + margin-bottom: 2.5rem; + max-width: 540px; } -.form-group { - margin-bottom: 24px; +.features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; } -.form-group label { - display: block; - margin-bottom: 8px; - color: var(--text-primary); - font-weight: 500; - font-size: 0.9rem; +.feature { + display: flex; + align-items: center; + gap: 0.75rem; + color: var(--text-muted); + font-size: 0.95rem; } -.form-group input { +.feature-icon { + width: 32px; + height: 32px; + background: rgba(15, 23, 42, 0.8); + border: 1px solid var(--border); + color: var(--primary); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +/* Config Card */ +.config-card { + background: var(--bg-card); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid rgba(255, 255, 255, 0.05); + border-top: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 24px; + padding: 2.5rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), inset 0 0 0 1px rgba(255, 255, 255, 0.02); width: 100%; - padding: 12px 16px; - background: var(--background); - border: 2px solid var(--border); - border-radius: 8px; - color: var(--text-primary); - font-size: 1rem; - transition: all 0.3s ease; + max-width: 500px; + margin: 0 auto; + position: relative; + overflow: hidden; } -.form-group input:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +/* Add a subtle shine effect */ +.config-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); +} + +.card-header { + text-align: center; + margin-bottom: 2rem; } -.form-group input::placeholder { - color: var(--text-secondary); - opacity: 0.6; +.logo { + width: 72px; + height: 72px; + margin-bottom: 1.5rem; + filter: drop-shadow(0 0 25px rgba(59, 130, 246, 0.4)); + transition: transform 0.3s ease; } -.radio-group { - display: flex; - flex-direction: column; - gap: 10px; - margin-top: 8px; +.logo:hover { + transform: scale(1.05); } -.radio-label { - display: flex; - align-items: center; - gap: 12px; - padding: 14px 16px; - background: var(--background); - border: 2px solid var(--border); - border-radius: 8px; - cursor: pointer; - transition: all 0.3s ease; - position: relative; +/* Forms */ +.form-group { + margin-bottom: 1.5rem; } -.radio-label:hover { - border-color: var(--primary-color); - background: rgba(59, 130, 246, 0.05); +.label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-muted); + margin-bottom: 0.5rem; } -.radio-label input[type="radio"] { - width: 18px; - height: 18px; - margin: 0; - cursor: pointer; - accent-color: var(--primary-color); - flex-shrink: 0; - } - - .radio-label span { - flex: 1; - color: var(--text-primary); - transition: color 0.3s ease; +.input-wrapper { + position: relative; + display: flex; + align-items: center; } -.radio-label input[type="radio"]:checked + span { - color: var(--primary-color); - font-weight: 500; +.input-icon { + position: absolute; + left: 12px; + color: var(--text-muted); + pointer-events: none; + display: flex; + align-items: center; } -.radio-label:has(input[type="radio"]:checked) { - border-color: var(--primary-color); - background: rgba(59, 130, 246, 0.1); +.input, .select { + width: 100%; + padding: 0.875rem 1rem; + background: rgba(15, 23, 42, 0.6); + border: 1px solid var(--border); + border-radius: 12px; + color: var(--text-main); + font-family: inherit; + font-size: 1rem; + transition: all 0.2s ease; } -/* Fallback for browsers that don't support :has() */ -.radio-label.checked { - border-color: var(--primary-color); - background: rgba(59, 130, 246, 0.1); +.input.has-icon { + padding-left: 2.5rem; } -.form-help { - margin-top: 10px; - font-size: 0.85rem; - color: var(--text-secondary); - line-height: 1.5; - font-style: italic; + +.input:focus, .select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--primary-light); + background: rgba(15, 23, 42, 0.8); +} + +.select { + appearance: none; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%2394a3b8' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-position: right 0.75rem center; + background-repeat: no-repeat; + background-size: 1.25em 1.25em; + padding-right: 2.5rem; + cursor: pointer; } -.btn-primary { - width: 100%; - padding: 14px 24px; - background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); - color: white; +/* Toggles */ +.toggle-btn { + position: absolute; + right: 0.75rem; + background: none; border: none; - border-radius: 8px; - font-size: 1rem; + color: var(--primary); + font-size: 0.75rem; font-weight: 600; cursor: pointer; - transition: all 0.3s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; + padding: 0.25rem 0.5rem; + border-radius: 6px; + transition: all 0.2s; } -.btn-primary:hover:not(:disabled) { - transform: translateY(-2px); - box-shadow: 0 8px 20px rgba(59, 130, 246, 0.4); +.toggle-btn:hover { + background: var(--primary-light); } -.btn-primary:disabled { - opacity: 0.6; - cursor: not-allowed; +/* Radio Tiles */ +.radio-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; } -.btn-secondary { - width: 100%; - padding: 12px 24px; - background: transparent; - color: var(--text-primary); - border: 2px solid var(--border); - border-radius: 8px; - font-size: 1rem; - font-weight: 500; +.radio-option { + position: relative; +} + +.radio-option input { + position: absolute; + opacity: 0; cursor: pointer; - transition: all 0.3s ease; - margin-top: 16px; + inset: 0; + z-index: 2; } -.btn-secondary:hover { - border-color: var(--primary-color); - color: var(--primary-color); +.radio-tile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1.25rem; + background: rgba(15, 23, 42, 0.6); + border: 1px solid var(--border); + border-radius: 16px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; } -.error-message { - background: rgba(239, 68, 68, 0.1); - border: 1px solid var(--error); - color: var(--error); - padding: 12px 16px; - border-radius: 8px; - margin-bottom: 16px; - font-size: 0.9rem; +.radio-tile::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at center, var(--primary-light), transparent 70%); + opacity: 0; + transition: opacity 0.3s; } -.success-section { - margin-top: 24px; +.radio-icon { + margin-bottom: 0.5rem; + color: var(--text-muted); + transition: all 0.3s; } -.success-header { - text-align: center; - margin-bottom: 24px; +.radio-tile span { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-muted); + transition: color 0.3s; } -.success-header h2 { - color: var(--success); - margin-bottom: 8px; - font-size: 1.5rem; +.radio-option input:checked + .radio-tile { + border-color: var(--primary); + background: rgba(15, 23, 42, 0.9); + box-shadow: 0 0 0 1px var(--primary), 0 10px 25px -5px var(--primary-light); + transform: translateY(-2px); } -.success-header p { - color: var(--text-secondary); - font-size: 0.95rem; +.radio-option input:checked + .radio-tile .radio-icon { + color: var(--primary); + transform: scale(1.1); } -.url-container { - margin-bottom: 20px; +.radio-option input:checked + .radio-tile span { + color: var(--text-main); } -.url-input { - width: 100%; - padding: 12px 16px; - background: var(--background); - border: 2px solid var(--border); - border-radius: 8px; - color: var(--text-primary); - font-size: 0.9rem; - font-family: 'Courier New', monospace; - word-break: break-all; +.radio-option input:checked + .radio-tile::before { + opacity: 1; } -.button-group { - display: flex; - flex-direction: column; - gap: 12px; - margin-bottom: 24px; +.radio-option:hover .radio-tile { + border-color: rgba(148, 163, 184, 0.3); } -.btn-install { +/* Buttons */ +.btn { width: 100%; - padding: 14px 20px; - background: var(--surface-light); - border: 2px solid var(--border); - border-radius: 8px; - color: var(--text-primary); - cursor: pointer; + padding: 1rem; + border: none; + border-radius: 12px; + font-weight: 600; font-size: 1rem; - font-weight: 500; - transition: all 0.3s ease; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); display: flex; align-items: center; justify-content: center; - gap: 8px; + gap: 0.75rem; + position: relative; + overflow: hidden; } -.btn-install:hover { - background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); - border-color: var(--primary-color); - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +.btn-primary { + background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%); + color: white; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2); } -.btn-install.btn-copy.copied { - background: var(--success); - border-color: var(--success); +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(59, 130, 246, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.2); } -.warning-box { - background: linear-gradient(135deg, rgba(245, 158, 11, 0.15) 0%, rgba(217, 119, 6, 0.15) 100%); - border: 2px solid #f59e0b; - border-radius: 12px; - padding: 20px; - margin-bottom: 24px; - display: flex; - gap: 16px; - align-items: flex-start; - box-shadow: 0 4px 12px rgba(245, 158, 11, 0.2); +.btn-primary:active { + transform: translateY(0); } -.warning-icon { - font-size: 2rem; - flex-shrink: 0; - line-height: 1; +.btn-outline { + background: rgba(15, 23, 42, 0.4); + border: 1px solid var(--border); + color: var(--text-muted); } -.warning-content { - flex: 1; +.btn-outline:hover { + border-color: var(--primary); + color: var(--text-main); + background: rgba(14, 165, 233, 0.1); + box-shadow: 0 0 15px rgba(14, 165, 233, 0.1); } -.warning-content strong { - display: block; - color: #fbbf24; - font-size: 1.1rem; - margin-bottom: 8px; - text-transform: uppercase; - letter-spacing: 0.5px; +/* Messages */ +.error-box { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.2); + color: #fca5a5; + padding: 1rem; + border-radius: 12px; + margin-bottom: 1.5rem; + font-size: 0.875rem; + display: none; + animation: shake 0.4s cubic-bezier(.36,.07,.19,.97) both; } -.warning-content p { - color: var(--text-primary); - margin: 0; - line-height: 1.6; - font-size: 0.95rem; +@keyframes shake { + 10%, 90% { transform: translate3d(-1px, 0, 0); } + 20%, 80% { transform: translate3d(2px, 0, 0); } + 30%, 50%, 70% { transform: translate3d(-4px, 0, 0); } + 40%, 60% { transform: translate3d(4px, 0, 0); } } -.warning-content p strong { - color: #f59e0b; - font-size: inherit; - text-transform: none; - letter-spacing: normal; - display: inline; +.success-view { + text-align: center; + display: none; + animation: fadeIn 0.5s ease-out; } -.instructions { - background: var(--background); - padding: 20px; - border-radius: 8px; - margin-bottom: 16px; - border: 1px solid var(--border); +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } } -.instructions h3 { - margin-bottom: 12px; - color: var(--text-primary); - font-size: 1.1rem; +.success-icon { + width: 80px; + height: 80px; + background: linear-gradient(135deg, rgba(16, 185, 129, 0.1), rgba(16, 185, 129, 0.05)); + color: var(--success); + border: 1px solid rgba(16, 185, 129, 0.2); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 1.5rem; + box-shadow: 0 0 30px rgba(16, 185, 129, 0.2); } -.instructions ol { - margin-left: 20px; - color: var(--text-secondary); +/* Utils */ +.helper-text { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.5rem; } -.instructions li { - margin-bottom: 8px; +.hidden { + display: none; } -.instructions strong { - color: var(--primary-color); +.url-box { + background: rgba(2, 6, 23, 0.6); + padding: 1.25rem; + border-radius: 12px; + border: 1px solid var(--border); + font-family: 'JetBrains Mono', monospace; + color: #38bdf8; + word-break: break-all; + margin-bottom: 1.5rem; + font-size: 0.875rem; + position: relative; } -.footer { - text-align: center; - margin-top: 24px; - color: var(--text-secondary); - font-size: 0.85rem; +/* Warning Box */ +.warning-box { + background: rgba(245, 158, 11, 0.08); + border: 1px solid rgba(245, 158, 11, 0.2); + border-radius: 12px; + padding: 1rem; + margin-bottom: 1.5rem; + display: flex; + gap: 1rem; + align-items: start; + text-align: left; } -@media (max-width: 600px) { - .card { - padding: 24px; - } - - .header h1 { - font-size: 2rem; - } - - .logo { - width: 60px; - height: 60px; - } +.warning-icon { + color: var(--warning); + flex-shrink: 0; +} - .warning-box { - padding: 16px; - gap: 12px; - } +.warning-content h4 { + color: #fbbf24; + font-size: 0.875rem; + margin-bottom: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} - .warning-icon { - font-size: 1.5rem; - } +/* Animations */ +@keyframes spin { + to { transform: rotate(360deg); } +} - .warning-content strong { - font-size: 1rem; - } +.loader { + width: 20px; + height: 20px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} - .warning-content p { - font-size: 0.9rem; - } -} \ No newline at end of file +/* Announcement */ +.announcement { + background: linear-gradient(to right, rgba(59, 130, 246, 0.1), rgba(6, 182, 212, 0.1)); + border: 1px solid rgba(59, 130, 246, 0.2); + padding: 0.75rem; + border-radius: 12px; + margin-bottom: 2rem; + font-size: 0.875rem; + color: var(--text-main); + display: flex; + align-items: center; + gap: 0.5rem; +} diff --git a/uv.lock b/uv.lock index d62f86e..feb9d81 100644 --- a/uv.lock +++ b/uv.lock @@ -35,6 +35,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + [[package]] name = "black" version = "25.11.0" @@ -92,6 +101,97 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -113,6 +213,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", size = 3689163, upload-time = "2025-10-15T23:18:13.821Z" }, + { url = "https://files.pythonhosted.org/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c", size = 3429474, upload-time = "2025-10-15T23:18:15.477Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, + { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -140,6 +314,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/23/dfb161e91db7c92727db505dc72a384ee79681fe0603f706f9f9f52c2901/fastapi-0.121.2-py3-none-any.whl", hash = "sha256:f2d80b49a86a846b70cc3a03eb5ea6ad2939298bf6a7fe377aa9cd3dd079d358", size = 109201, upload-time = "2025-11-13T17:05:52.718Z" }, ] +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + [[package]] name = "flake9" version = "3.8.3.post2" @@ -234,6 +417,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -274,6 +466,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -301,6 +502,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] +[[package]] +name = "pre-commit" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz", hash = "sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size = 197501, upload-time = "2025-11-08T21:12:11.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049, upload-time = "2025-11-08T21:12:10.228Z" }, +] + [[package]] name = "pycodestyle" version = "2.6.0" @@ -310,6 +527,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/5b/88879fb861ab79aef45c7e199cae3ef7af487b5603dcb363517a50602dd7/pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", size = 41364, upload-time = "2020-05-11T20:02:52.968Z" }, ] +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + [[package]] name = "pydantic" version = "2.12.4" @@ -548,6 +774,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "redis" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -709,6 +947,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +] + [[package]] name = "watchfiles" version = "1.1.1" @@ -818,11 +1071,13 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "cachetools" }, + { name = "cryptography" }, { name = "fastapi" }, { name = "httpx" }, { name = "loguru" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "redis" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -830,16 +1085,19 @@ dependencies = [ dev = [ { name = "black" }, { name = "flake9" }, + { name = "pre-commit" }, ] [package.metadata] requires-dist = [ { name = "cachetools", specifier = ">=6.2.2" }, + { name = "cryptography", specifier = ">=46.0.3" }, { name = "fastapi", specifier = ">=0.104.1" }, { name = "httpx", specifier = ">=0.25.2" }, { name = "loguru", specifier = ">=0.7.2" }, { name = "pydantic", specifier = ">=2.5.0" }, { name = "pydantic-settings", specifier = ">=2.1.0" }, + { name = "redis", specifier = ">=5.0.1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, ] @@ -847,6 +1105,7 @@ requires-dist = [ dev = [ { name = "black", specifier = ">=25.11.0" }, { name = "flake9", specifier = ">=3.8.3.post2" }, + { name = "pre-commit", specifier = ">=4.4.0" }, ] [[package]] diff --git a/vercel.json b/vercel.json index 2bb5805..56bfdd3 100644 --- a/vercel.json +++ b/vercel.json @@ -13,4 +13,3 @@ } ] } -