commit b969b7e5afb960c7b26528757541399d69e173b8 Author: KiraStream Date: Tue May 19 14:43:31 2026 +0000 Mejoras de estabilidad de stream IPTV y nuevo registro de fallos - restream.py: eliminar -reconnect_streamed de ffmpeg para evitar rebobinados al reconectar (el proveedor re-enviaba desde keyframe anterior causando PTS backward). El loop de Python gestiona las reconexiones con conexión fresca. - restream.py: eliminar _drain_queue del loop de reconexión para que el buffer de la cola cubra el tiempo de reconexión sin pantalla negra en el player. - restream.py: añadir buffer_server_bytes/secs/capacity a stats() y método _log_stream_event() para persistir eventos de caída y recuperación en BD. - models/stream_event.py: nuevo modelo StreamEvent para registro de fallos. - database.py: registrar StreamEvent en init_db. - api/admin/logs.py: nuevos endpoints GET/DELETE /logs/events. - Dashboard.tsx: mostrar reserva de buffer del servidor (capacidad + estado). - Logs.tsx: añadir pestaña "Fallos de stream" con tabla de eventos persistidos. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc4f4b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Python +venv/ +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ + +# Environment & secrets +.env +*.env +.env.* + +# Logs & runtime +logs/ +*.log +backend.pid +dump.rdb + +# Frontend build artifacts (built files are in backend/static, kept) +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ +frontend/tsconfig.tsbuildinfo + +# Database files +*.db +*.sqlite3 + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*.bak + +# Nginx runtime +nginx/logs/ diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/admin/__init__.py b/backend/api/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/admin/catalogs.py b/backend/api/admin/catalogs.py new file mode 100644 index 0000000..f8972a1 --- /dev/null +++ b/backend/api/admin/catalogs.py @@ -0,0 +1,90 @@ +"""Admin API: category/catalog management.""" +from fastapi import APIRouter, Depends +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +from ...database import get_db +from ...models.channel import Category, Channel, ChannelProviderMap, ContentType +from ..auth import get_current_admin + +router = APIRouter(prefix="/catalogs", tags=["catalogs"]) + + +class CategoryOut(BaseModel): + id: int + name: str + type: ContentType + provider_category_id: str | None + provider_account_id: int | None + + class Config: + from_attributes = True + + +class GroupedCategoryOut(BaseModel): + name: str + type: ContentType + category_ids: list[int] + provider_count: int # distinct providers that can serve channels in this category + + +@router.get("/", response_model=list[CategoryOut]) +async def list_categories( + content_type: ContentType | None = None, + db: AsyncSession = Depends(get_db), + _=Depends(get_current_admin), +): + q = select(Category).order_by(Category.type, Category.name) + if content_type: + q = q.where(Category.type == content_type) + result = await db.execute(q) + return result.scalars().all() + + +@router.get("/grouped", response_model=list[GroupedCategoryOut]) +async def list_categories_grouped( + content_type: ContentType | None = None, + db: AsyncSession = Depends(get_db), + _=Depends(get_current_admin), +): + """Returns categories merged by name+type. Includes how many providers can serve each.""" + q = select(Category).order_by(Category.type, Category.name) + if content_type: + q = q.where(Category.type == content_type) + cats_result = await db.execute(q) + all_cats = cats_result.scalars().all() + + # Group by (name_normalized, type) + seen: dict[tuple[str, str], dict] = {} + for cat in all_cats: + key = (cat.name.strip().lower(), str(cat.type)) + if key not in seen: + seen[key] = {"name": cat.name, "type": cat.type, "category_ids": [], "provider_count": 0} + seen[key]["category_ids"].append(cat.id) + + if not seen: + return [] + + # Map each category_id back to its group key for the count query + cat_id_to_key: dict[int, tuple[str, str]] = {} + for key, group in seen.items(): + for cat_id in group["category_ids"]: + cat_id_to_key[cat_id] = key + + # Single query: distinct providers per category_id — avoids locale-dependent lower() issues + pcount_q = await db.execute( + select( + Channel.category_id, + func.count(func.distinct(ChannelProviderMap.provider_account_id)).label("pcount"), + ) + .join(ChannelProviderMap, ChannelProviderMap.channel_id == Channel.id) + .where(Channel.category_id.in_(list(cat_id_to_key.keys()))) + .group_by(Channel.category_id) + ) + for row in pcount_q: + key = cat_id_to_key.get(row.category_id) + if key and key in seen: + seen[key]["provider_count"] = max(seen[key]["provider_count"], row.pcount) + + return list(seen.values()) diff --git a/backend/api/admin/custom_categories.py b/backend/api/admin/custom_categories.py new file mode 100644 index 0000000..002e7ba --- /dev/null +++ b/backend/api/admin/custom_categories.py @@ -0,0 +1,186 @@ +"""CRUD for custom categories and their channel assignments.""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from ...database import get_db +from ...models.channel import Channel, ContentType +from ...models.custom_category import CustomCategory, CustomCategoryItem +from ..auth import get_current_admin + +router = APIRouter(prefix="/custom-categories", tags=["custom-categories"]) + + +class CategoryCreate(BaseModel): + name: str + type: str # live, movie, series + + +class CategoryUpdate(BaseModel): + name: str | None = None + is_visible: bool | None = None + position: int | None = None + + +class AddChannelRequest(BaseModel): + channel_id: int + + +# --------------------------------------------------------------------------- +# Category CRUD +# --------------------------------------------------------------------------- + +@router.get("") +async def list_categories(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute( + select(CustomCategory) + .options(selectinload(CustomCategory.items)) + .order_by(CustomCategory.position, CustomCategory.id) + ) + cats = result.scalars().all() + return [ + { + "id": c.id, + "name": c.name, + "type": c.type.value, + "position": c.position, + "is_visible": c.is_visible, + "item_count": len(c.items), + "created_at": c.created_at.isoformat() if c.created_at else None, + } + for c in cats + ] + + +@router.post("", status_code=201) +async def create_category(data: CategoryCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + try: + ctype = ContentType(data.type) + except ValueError: + raise HTTPException(400, "type must be one of: live, movie, series") + cat = CustomCategory(name=data.name.strip(), type=ctype) + db.add(cat) + await db.commit() + await db.refresh(cat) + return {"id": cat.id, "name": cat.name, "type": cat.type.value, "position": cat.position, "is_visible": cat.is_visible, "item_count": 0} + + +@router.patch("/{cat_id}") +async def update_category(cat_id: int, data: CategoryUpdate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute(select(CustomCategory).where(CustomCategory.id == cat_id)) + cat = result.scalar_one_or_none() + if not cat: + raise HTTPException(404, "Not found") + if data.name is not None: + cat.name = data.name.strip() + if data.is_visible is not None: + cat.is_visible = data.is_visible + if data.position is not None: + cat.position = data.position + await db.commit() + return {"ok": True} + + +@router.delete("/{cat_id}", status_code=204) +async def delete_category(cat_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute(select(CustomCategory).where(CustomCategory.id == cat_id)) + cat = result.scalar_one_or_none() + if not cat: + raise HTTPException(404, "Not found") + await db.delete(cat) + await db.commit() + + +# --------------------------------------------------------------------------- +# Channel assignments +# --------------------------------------------------------------------------- + +@router.get("/{cat_id}/channels") +async def list_category_channels(cat_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute( + select(CustomCategoryItem, Channel) + .join(Channel, Channel.id == CustomCategoryItem.channel_id) + .where(CustomCategoryItem.custom_category_id == cat_id) + .order_by(CustomCategoryItem.position, CustomCategoryItem.id) + ) + rows = result.all() + return [ + { + "item_id": row.CustomCategoryItem.id, + "channel_id": row.Channel.id, + "name": row.Channel.name, + "logo": row.Channel.tvg_logo or "", + "type": row.Channel.type.value, + } + for row in rows + ] + + +@router.post("/{cat_id}/channels", status_code=201) +async def add_channel(cat_id: int, data: AddChannelRequest, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + # Verify category exists + cat_result = await db.execute(select(CustomCategory).where(CustomCategory.id == cat_id)) + if not cat_result.scalar_one_or_none(): + raise HTTPException(404, "Category not found") + # Verify channel exists + ch_result = await db.execute(select(Channel).where(Channel.id == data.channel_id)) + if not ch_result.scalar_one_or_none(): + raise HTTPException(404, "Channel not found") + # Check duplicate + dup = await db.execute( + select(CustomCategoryItem).where( + CustomCategoryItem.custom_category_id == cat_id, + CustomCategoryItem.channel_id == data.channel_id, + ) + ) + if dup.scalar_one_or_none(): + raise HTTPException(409, "Channel already in category") + item = CustomCategoryItem(custom_category_id=cat_id, channel_id=data.channel_id) + db.add(item) + await db.commit() + return {"ok": True} + + +@router.delete("/{cat_id}/channels/{item_id}", status_code=204) +async def remove_channel(cat_id: int, item_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute( + select(CustomCategoryItem).where( + CustomCategoryItem.id == item_id, + CustomCategoryItem.custom_category_id == cat_id, + ) + ) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(404, "Not found") + await db.delete(item) + await db.commit() + + +# --------------------------------------------------------------------------- +# Channel search (for the picker) +# --------------------------------------------------------------------------- + +@router.get("/channels/search") +async def search_channels( + q: str = "", + type: str = "", + db: AsyncSession = Depends(get_db), + _=Depends(get_current_admin), +): + query = select(Channel).where(Channel.is_active == True) # noqa: E712 + if type: + try: + query = query.where(Channel.type == ContentType(type)) + except ValueError: + pass + if q: + query = query.where(Channel.name.ilike(f"%{q}%")) + query = query.order_by(Channel.name).limit(60) + result = await db.execute(query) + channels = result.scalars().all() + return [ + {"id": ch.id, "name": ch.name, "logo": ch.tvg_logo or "", "type": ch.type.value} + for ch in channels + ] diff --git a/backend/api/admin/epg_admin.py b/backend/api/admin/epg_admin.py new file mode 100644 index 0000000..c26c729 --- /dev/null +++ b/backend/api/admin/epg_admin.py @@ -0,0 +1,60 @@ +"""Admin API: EPG source management.""" +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +from ...database import get_db +from ...models.epg import EpgSource +from ...core.epg_manager import refresh_epg +from ..auth import get_current_admin + +router = APIRouter(prefix="/epg", tags=["epg"]) + + +class EpgSourceCreate(BaseModel): + name: str + url: str + + +class EpgSourceOut(BaseModel): + id: int + name: str + url: str + is_active: bool + last_fetched_at: str | None = None + + class Config: + from_attributes = True + + +@router.get("/", response_model=list[EpgSourceOut]) +async def list_epg_sources(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute(select(EpgSource).order_by(EpgSource.id)) + return result.scalars().all() + + +@router.post("/", response_model=EpgSourceOut) +async def create_epg_source(data: EpgSourceCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + source = EpgSource(**data.model_dump()) + db.add(source) + await db.commit() + await db.refresh(source) + return source + + +@router.delete("/{source_id}") +async def delete_epg_source(source_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute(select(EpgSource).where(EpgSource.id == source_id)) + source = result.scalar_one_or_none() + if not source: + raise HTTPException(404, "EPG source not found") + await db.delete(source) + await db.commit() + return {"ok": True} + + +@router.post("/refresh") +async def trigger_refresh(background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + background_tasks.add_task(refresh_epg, db) + return {"ok": True, "message": "EPG refresh started in background"} diff --git a/backend/api/admin/jellyfin_admin.py b/backend/api/admin/jellyfin_admin.py new file mode 100644 index 0000000..f3baa93 --- /dev/null +++ b/backend/api/admin/jellyfin_admin.py @@ -0,0 +1,406 @@ +"""Admin API for Jellyfin integration — CRUD, library discovery, sync.""" +import asyncio +import logging +from datetime import datetime, timezone +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from ...core.jellyfin_client import JellyfinClient +from ...database import get_db, AsyncSessionLocal +from ...models.channel import Channel, Category, ChannelProviderMap, ContentType +from ...models.jellyfin import JellyfinConfig, JellyfinItem +from ...models.provider import ProviderAccount +from ..auth import get_current_admin + +router = APIRouter(prefix="/jellyfin", tags=["jellyfin"]) +logger = logging.getLogger(__name__) + +# sync_id → {"status": "running"|"done"|"error", "message": str, "synced": int} +_sync_status: dict[int, dict] = {} + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + +class JellyfinConfigCreate(BaseModel): + name: str + url: str + api_key: str + movies_category_name: str = "Películas KiraTV" + series_category_name: str = "Series KiraTV" + movies_library_id: str | None = None + series_library_id: str | None = None + + +class JellyfinConfigUpdate(BaseModel): + name: str | None = None + url: str | None = None + api_key: str | None = None + movies_category_name: str | None = None + series_category_name: str | None = None + movies_library_id: str | None = None + series_library_id: str | None = None + is_active: bool | None = None + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + +@router.get("", dependencies=[Depends(get_current_admin)]) +async def list_configs(db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(JellyfinConfig).options(selectinload(JellyfinConfig.items)) + .order_by(JellyfinConfig.id) + ) + configs = result.scalars().all() + return [_config_dict(c) for c in configs] + + +@router.post("", dependencies=[Depends(get_current_admin)]) +async def create_config(body: JellyfinConfigCreate, db: AsyncSession = Depends(get_db)): + cfg = JellyfinConfig( + name=body.name, + url=body.url.rstrip("/"), + api_key=body.api_key, + movies_category_name=body.movies_category_name, + series_category_name=body.series_category_name, + movies_library_id=body.movies_library_id, + series_library_id=body.series_library_id, + ) + db.add(cfg) + await db.commit() + await db.refresh(cfg) + return _config_dict(cfg) + + +@router.put("/{cfg_id}", dependencies=[Depends(get_current_admin)]) +async def update_config(cfg_id: int, body: JellyfinConfigUpdate, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id)) + cfg = result.scalar_one_or_none() + if not cfg: + raise HTTPException(404, "Not found") + if body.name is not None: + cfg.name = body.name + if body.url is not None: + cfg.url = body.url.rstrip("/") + if body.api_key is not None: + cfg.api_key = body.api_key + if body.movies_category_name is not None: + cfg.movies_category_name = body.movies_category_name + if body.series_category_name is not None: + cfg.series_category_name = body.series_category_name + if body.movies_library_id is not None: + cfg.movies_library_id = body.movies_library_id + if body.series_library_id is not None: + cfg.series_library_id = body.series_library_id + if body.is_active is not None: + cfg.is_active = body.is_active + await db.commit() + await db.refresh(cfg) + return _config_dict(cfg) + + +@router.delete("/{cfg_id}", dependencies=[Depends(get_current_admin)]) +async def delete_config(cfg_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id)) + cfg = result.scalar_one_or_none() + if not cfg: + raise HTTPException(404, "Not found") + await db.delete(cfg) + await db.commit() + return {"ok": True} + + +# --------------------------------------------------------------------------- +# Test connection + library list +# --------------------------------------------------------------------------- + +@router.post("/{cfg_id}/test", dependencies=[Depends(get_current_admin)]) +async def test_connection(cfg_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id)) + cfg = result.scalar_one_or_none() + if not cfg: + raise HTTPException(404, "Not found") + client = JellyfinClient(cfg.url, cfg.api_key) + try: + info = await client.test_connection() + return {"ok": True, "server_name": info.get("ServerName", ""), "version": info.get("Version", "")} + except Exception as e: + return {"ok": False, "error": str(e)} + + +@router.get("/{cfg_id}/libraries", dependencies=[Depends(get_current_admin)]) +async def get_libraries(cfg_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id)) + cfg = result.scalar_one_or_none() + if not cfg: + raise HTTPException(404, "Not found") + client = JellyfinClient(cfg.url, cfg.api_key) + try: + libs = await client.get_libraries() + return [ + { + "id": lib.get("ItemId", ""), + "name": lib.get("Name", ""), + "type": lib.get("CollectionType", ""), + } + for lib in libs + ] + except Exception as e: + raise HTTPException(502, f"Jellyfin unreachable: {e}") + + +# --------------------------------------------------------------------------- +# Sync +# --------------------------------------------------------------------------- + +@router.post("/{cfg_id}/sync", dependencies=[Depends(get_current_admin)]) +async def start_sync(cfg_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id)) + cfg = result.scalar_one_or_none() + if not cfg: + raise HTTPException(404, "Not found") + if _sync_status.get(cfg_id, {}).get("status") == "running": + return {"status": "already_running"} + _sync_status[cfg_id] = {"status": "running", "message": "Iniciando…", "synced": 0} + asyncio.create_task(_do_sync(cfg_id)) + return {"status": "started"} + + +@router.get("/{cfg_id}/sync-status", dependencies=[Depends(get_current_admin)]) +async def sync_status(cfg_id: int): + return _sync_status.get(cfg_id, {"status": "idle", "message": "", "synced": 0}) + + +# --------------------------------------------------------------------------- +# Internal sync implementation +# --------------------------------------------------------------------------- + +async def _do_sync(cfg_id: int) -> None: + try: + async with AsyncSessionLocal() as db: + await _sync_jellyfin(cfg_id, db) + _sync_status[cfg_id] = {"status": "done", "message": "Sincronización completada", "synced": _sync_status[cfg_id].get("synced", 0)} + except Exception as e: + logger.error(f"Jellyfin sync error for config {cfg_id}: {e}", exc_info=True) + _sync_status[cfg_id] = {"status": "error", "message": str(e), "synced": 0} + + +async def _sync_jellyfin(cfg_id: int, db: AsyncSession) -> None: + result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id)) + cfg = result.scalar_one_or_none() + if not cfg: + raise ValueError(f"JellyfinConfig {cfg_id} not found") + + client = JellyfinClient(cfg.url, cfg.api_key) + _sync_status[cfg_id]["message"] = "Conectando a Jellyfin…" + + # 1. Ensure dummy ProviderAccount exists + provider = await _get_or_create_provider(cfg, db) + if cfg.provider_account_id != provider.id: + cfg.provider_account_id = provider.id + await db.flush() + + total_synced = 0 + + # 2. Sync movies + if cfg.movies_library_id: + _sync_status[cfg_id]["message"] = "Obteniendo películas…" + movies = await client.get_items(cfg.movies_library_id, "Movie") + _sync_status[cfg_id]["message"] = f"Sincronizando {len(movies)} películas…" + movie_cat = await _get_or_create_category( + db, cfg.movies_category_name, ContentType.movie, provider.id, "jellyfin_movies" + ) + for item in movies: + await _upsert_channel(db, cfg, provider, movie_cat, item, ContentType.movie, client) + total_synced += 1 + _sync_status[cfg_id]["synced"] = total_synced + + # 3. Sync series + if cfg.series_library_id: + _sync_status[cfg_id]["message"] = "Obteniendo series…" + series_list = await client.get_items(cfg.series_library_id, "Series") + _sync_status[cfg_id]["message"] = f"Sincronizando {len(series_list)} series…" + series_cat = await _get_or_create_category( + db, cfg.series_category_name, ContentType.series, provider.id, "jellyfin_series" + ) + for item in series_list: + await _upsert_channel(db, cfg, provider, series_cat, item, ContentType.series, client) + total_synced += 1 + _sync_status[cfg_id]["synced"] = total_synced + + cfg.last_sync_at = datetime.now(timezone.utc) + await db.commit() + logger.info(f"Jellyfin sync complete for config {cfg_id}: {total_synced} items") + + +async def _get_or_create_provider(cfg: JellyfinConfig, db: AsyncSession) -> ProviderAccount: + if cfg.provider_account_id: + result = await db.execute( + select(ProviderAccount).where(ProviderAccount.id == cfg.provider_account_id) + ) + existing = result.scalar_one_or_none() + if existing: + return existing + + provider = ProviderAccount( + name=f"[Jellyfin] {cfg.name}", + base_url=cfg.url, + username="jellyfin", + password=cfg.api_key, + max_connections=999, + is_active=True, + ) + db.add(provider) + await db.flush() + return provider + + +async def _get_or_create_category( + db: AsyncSession, + name: str, + ctype: ContentType, + provider_id: int, + provider_cat_id: str, +) -> Category: + result = await db.execute( + select(Category).where( + Category.provider_category_id == provider_cat_id, + Category.provider_account_id == provider_id, + Category.type == ctype, + ) + ) + cat = result.scalar_one_or_none() + if cat: + cat.name = name + return cat + cat = Category( + name=name, + type=ctype, + provider_category_id=provider_cat_id, + provider_account_id=provider_id, + ) + db.add(cat) + await db.flush() + return cat + + +async def _upsert_channel( + db: AsyncSession, + cfg: JellyfinConfig, + provider: ProviderAccount, + category: Category, + item: dict, + ctype: ContentType, + client: JellyfinClient, +) -> None: + jellyfin_id = item.get("Id", "") + if not jellyfin_id: + return + + stream_id = f"jf_{jellyfin_id}" + stream_url = client.build_stream_url(jellyfin_id) + image_url = client.build_image_url(jellyfin_id) + name = item.get("Name", "") + plot = item.get("Overview") or "" + year = item.get("ProductionYear") + genres = ", ".join(item.get("Genres", [])) + rating = item.get("OfficialRating") or "" + + # Find or create Channel + ch_result = await db.execute( + select(Channel) + .options(selectinload(Channel.provider_maps)) + .where(Channel.stream_id_at_provider == stream_id, Channel.type == ctype) + ) + ch = ch_result.scalar_one_or_none() + if not ch: + ch = Channel( + name=name, + stream_id_at_provider=stream_id, + type=ctype, + category_id=category.id, + tvg_logo=image_url, + is_active=True, + ) + db.add(ch) + await db.flush() + else: + ch.name = name + ch.tvg_logo = image_url + ch.category_id = category.id + ch.is_active = True + + # ChannelProviderMap + pmap_result = await db.execute( + select(ChannelProviderMap).where( + ChannelProviderMap.channel_id == ch.id, + ChannelProviderMap.provider_account_id == provider.id, + ) + ) + pmap = pmap_result.scalar_one_or_none() + if not pmap: + pmap = ChannelProviderMap( + channel_id=ch.id, + provider_account_id=provider.id, + stream_url=stream_url, + ) + db.add(pmap) + else: + pmap.stream_url = stream_url + + # JellyfinItem + ji_result = await db.execute( + select(JellyfinItem).where( + JellyfinItem.jellyfin_config_id == cfg.id, + JellyfinItem.jellyfin_item_id == jellyfin_id, + ) + ) + ji = ji_result.scalar_one_or_none() + if not ji: + ji = JellyfinItem( + jellyfin_config_id=cfg.id, + channel_id=ch.id, + jellyfin_item_id=jellyfin_id, + item_type=ctype.value, + plot=plot, + year=year, + genres=genres, + rating=rating, + ) + db.add(ji) + else: + ji.channel_id = ch.id + ji.plot = plot + ji.year = year + ji.genres = genres + ji.rating = rating + + await db.flush() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _config_dict(cfg: JellyfinConfig) -> dict: + return { + "id": cfg.id, + "name": cfg.name, + "url": cfg.url, + "api_key": cfg.api_key, + "is_active": cfg.is_active, + "movies_library_id": cfg.movies_library_id, + "series_library_id": cfg.series_library_id, + "movies_category_name": cfg.movies_category_name, + "series_category_name": cfg.series_category_name, + "last_sync_at": cfg.last_sync_at.isoformat() if cfg.last_sync_at else None, + "item_count": len(cfg.__dict__.get("items", [])), + } diff --git a/backend/api/admin/logs.py b/backend/api/admin/logs.py new file mode 100644 index 0000000..d8a648a --- /dev/null +++ b/backend/api/admin/logs.py @@ -0,0 +1,195 @@ +"""Connection log API — query and stats.""" +from datetime import datetime + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select, func, desc, distinct +from sqlalchemy.ext.asyncio import AsyncSession + +from ...database import get_db +from ...models.log import ConnectionLog +from ...models.stream_event import StreamEvent +from ..auth import get_current_admin + +router = APIRouter(prefix="/logs", tags=["logs"]) + + +@router.get("") +async def get_logs( + username: str | None = Query(None), + channel: str | None = Query(None), + content_type: str | None = Query(None), + date_from: str | None = Query(None), + date_to: str | None = Query(None), + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=200), + db: AsyncSession = Depends(get_db), + _=Depends(get_current_admin), +): + q = select(ConnectionLog).order_by(desc(ConnectionLog.started_at)) + + if username: + q = q.where(ConnectionLog.username.ilike(f"%{username}%")) + if channel: + q = q.where(ConnectionLog.channel_name.ilike(f"%{channel}%")) + if content_type: + q = q.where(ConnectionLog.content_type == content_type) + if date_from: + try: + q = q.where(ConnectionLog.started_at >= datetime.fromisoformat(date_from)) + except ValueError: + pass + if date_to: + try: + q = q.where(ConnectionLog.started_at <= datetime.fromisoformat(date_to)) + except ValueError: + pass + + total_result = await db.execute(select(func.count()).select_from(q.subquery())) + total = total_result.scalar() or 0 + + q = q.offset((page - 1) * limit).limit(limit) + result = await db.execute(q) + logs = result.scalars().all() + + return { + "total": total, + "page": page, + "limit": limit, + "items": [_serialize(log) for log in logs], + } + + +@router.get("/stats") +async def get_stats(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + top_users_q = await db.execute( + select( + ConnectionLog.username, + func.count().label("sessions"), + func.sum(ConnectionLog.bytes_transferred).label("bytes"), + ) + .group_by(ConnectionLog.username) + .order_by(desc("sessions")) + .limit(10) + ) + + top_channels_q = await db.execute( + select(ConnectionLog.channel_name, func.count().label("sessions")) + .where(ConnectionLog.channel_name.isnot(None)) + .group_by(ConnectionLog.channel_name) + .order_by(desc("sessions")) + .limit(10) + ) + + totals_q = await db.execute( + select( + func.count().label("total_sessions"), + func.sum(ConnectionLog.bytes_transferred).label("total_bytes"), + func.avg(ConnectionLog.duration_seconds).label("avg_duration"), + func.count(distinct(ConnectionLog.username)).label("unique_users"), + ) + ) + t = totals_q.one() + + return { + "total_sessions": t.total_sessions or 0, + "total_bytes": t.total_bytes or 0, + "avg_duration_seconds": round(t.avg_duration or 0), + "unique_users": t.unique_users or 0, + "top_users": [ + {"username": r.username, "sessions": r.sessions, "bytes": r.bytes or 0} + for r in top_users_q + ], + "top_channels": [ + {"channel_name": r.channel_name, "sessions": r.sessions} + for r in top_channels_q + ], + } + + +@router.delete("", status_code=204) +async def clear_logs(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + """Delete all logs (admin use).""" + from sqlalchemy import delete + await db.execute(delete(ConnectionLog)) + await db.commit() + + +@router.get("/events") +async def get_events( + channel: str | None = Query(None), + event_type: str | None = Query(None), + date_from: str | None = Query(None), + date_to: str | None = Query(None), + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=200), + db: AsyncSession = Depends(get_db), + _=Depends(get_current_admin), +): + q = select(StreamEvent).order_by(desc(StreamEvent.created_at)) + + if channel: + q = q.where(StreamEvent.channel_name.ilike(f"%{channel}%")) + if event_type: + q = q.where(StreamEvent.event_type == event_type) + if date_from: + try: + q = q.where(StreamEvent.created_at >= datetime.fromisoformat(date_from)) + except ValueError: + pass + if date_to: + try: + q = q.where(StreamEvent.created_at <= datetime.fromisoformat(date_to)) + except ValueError: + pass + + total_result = await db.execute(select(func.count()).select_from(q.subquery())) + total = total_result.scalar() or 0 + + q = q.offset((page - 1) * limit).limit(limit) + result = await db.execute(q) + events = result.scalars().all() + + return { + "total": total, + "page": page, + "limit": limit, + "items": [_serialize_event(ev) for ev in events], + } + + +@router.delete("/events", status_code=204) +async def clear_events(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + from sqlalchemy import delete + await db.execute(delete(StreamEvent)) + await db.commit() + + +def _serialize_event(ev: StreamEvent) -> dict: + return { + "id": ev.id, + "channel_id": ev.channel_id, + "channel_name": ev.channel_name, + "provider_name": ev.provider_name, + "event_type": ev.event_type, + "domain": ev.domain, + "error_message": ev.error_message, + "attempt_number": ev.attempt_number, + "created_at": ev.created_at.isoformat() if ev.created_at else None, + } + + +def _serialize(log: ConnectionLog) -> dict: + return { + "id": log.id, + "user_id": log.user_id, + "username": log.username, + "channel_id": log.channel_id, + "channel_name": log.channel_name, + "content_type": log.content_type, + "stream_id": log.stream_id, + "started_at": log.started_at.isoformat() if log.started_at else None, + "ended_at": log.ended_at.isoformat() if log.ended_at else None, + "duration_seconds": log.duration_seconds, + "bytes_transferred": log.bytes_transferred, + "client_ip": log.client_ip, + } diff --git a/backend/api/admin/monitoring.py b/backend/api/admin/monitoring.py new file mode 100644 index 0000000..1aab94c --- /dev/null +++ b/backend/api/admin/monitoring.py @@ -0,0 +1,132 @@ +"""Admin API: real-time stream monitoring via REST and WebSocket.""" +import asyncio +import json +import logging + +import redis.asyncio as aioredis +from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...config import settings +from ...core.pool import pool, MONITORING_KEY +from ...core.vod_tracker import vod_tracker +from ...database import get_db +from ...models.jellyfin import JellyfinConfig +from ...models.provider import ProviderAccount +from ..auth import get_current_admin + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/monitoring", tags=["monitoring"]) + + +def _all_streams() -> list[dict]: + """Combined live + VOD/series sessions.""" + return pool.get_active_streams() + vod_tracker.get_all() + + +@router.get("/streams") +async def get_active_streams(_=Depends(get_current_admin)): + return _all_streams() + + +@router.get("/slots") +async def get_provider_slots(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + # Exclude Jellyfin dummy providers — they are local, have no slot limits + jf_subq = ( + select(JellyfinConfig.provider_account_id) + .where(JellyfinConfig.provider_account_id.isnot(None)) + .scalar_subquery() + ) + result = await db.execute( + select(ProviderAccount).where( + ProviderAccount.is_active == True, # noqa: E712 + ProviderAccount.id.notin_(jf_subq), + ) + ) + accounts = result.scalars().all() + + active_streams = _all_streams() + busy_accounts = {s["provider_account_id"] for s in active_streams} + + return [ + { + "id": acc.id, + "name": acc.name, + "max_connections": acc.max_connections, + "status": "busy" if acc.id in busy_accounts else "free", + "channel_id": next( + (s["channel_id"] for s in active_streams if s["provider_account_id"] == acc.id), None + ), + } + for acc in accounts + ] + + +class KillRequest(BaseModel): + client_id: str + + +@router.post("/kill") +async def kill_connection(body: KillRequest, _=Depends(get_current_admin)): + """Force-disconnect a client. Works for both live streams and VOD/series sessions.""" + cid = body.client_id + if cid.startswith("vod-"): + killed = vod_tracker.kill(cid) + else: + killed = await pool.kill_client(cid) + if not killed: + raise HTTPException(404, f"Client '{cid}' not found in any active stream") + return {"ok": True, "client_id": cid} + + +@router.websocket("/ws") +async def monitoring_websocket(websocket: WebSocket): + await websocket.accept() + redis_client = aioredis.from_url(settings.redis_url, decode_responses=True) + pubsub = redis_client.pubsub() + + try: + await pubsub.subscribe(MONITORING_KEY) + + # Send initial combined state + await websocket.send_text(json.dumps({"type": "state", "data": _all_streams()})) + + async def redis_reader(): + """Forward live-stream pool events from Redis pub/sub.""" + async for message in pubsub.listen(): + if message["type"] == "message": + try: + # Pool publishes only live streams; merge with VOD here + await websocket.send_text(json.dumps({"type": "update", "data": _all_streams()})) + except Exception: + return + + async def broadcaster(): + """Push full state every 2 s for VOD bytes/bps updates.""" + while True: + await asyncio.sleep(2) + try: + await websocket.send_text(json.dumps({"type": "update", "data": _all_streams()})) + except Exception: + return + + reader_task = asyncio.create_task(redis_reader()) + broadcaster_task = asyncio.create_task(broadcaster()) + + try: + await asyncio.gather(reader_task, broadcaster_task) + except WebSocketDisconnect: + pass + finally: + reader_task.cancel() + broadcaster_task.cancel() + + except WebSocketDisconnect: + pass + except Exception as e: + logger.error(f"Monitoring WS error: {e}") + finally: + await pubsub.unsubscribe(MONITORING_KEY) + await redis_client.aclose() diff --git a/backend/api/admin/providers.py b/backend/api/admin/providers.py new file mode 100644 index 0000000..f4a1128 --- /dev/null +++ b/backend/api/admin/providers.py @@ -0,0 +1,833 @@ +"""Admin API: provider account management + sync.""" +import asyncio +import json +import logging +from datetime import datetime, timezone +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +from sqlalchemy.orm import selectinload +from ...database import get_db, AsyncSessionLocal +from ...models.provider import ProviderAccount, ProviderUrl +from ...models.channel import Category, Channel, ChannelProviderMap, ContentType +from ...models.notification import NotificationDismissal +from ...models.epg import EpgSource +from ...core.xtream_client import XtreamClient +from ...core.epg_manager import refresh_epg +from ..auth import get_current_admin + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/providers", tags=["providers"]) + +# Track ongoing sync tasks: provider_id -> status dict +_sync_status: dict[int, dict] = {} + + +class ProviderCreate(BaseModel): + name: str + base_url: str + username: str + password: str + max_connections: int = 1 + auto_sync_hours: int = 0 # 0=disabled, 6/12/24=interval in hours + + +class ProviderUpdate(BaseModel): + name: str + base_url: str + username: str + password: str | None = None # None = keep existing + max_connections: int = 1 + auto_sync_hours: int = 0 + + +class ProviderUrlOut(BaseModel): + id: int + url: str + priority: int + is_active: bool + status: str + response_ms: int | None = None + last_checked_at: datetime | None = None + + class Config: + from_attributes = True + + +class ProviderOut(BaseModel): + id: int + name: str + base_url: str + username: str + max_connections: int + is_active: bool + last_sync_at: datetime | None + expiry_date: datetime | None = None + auto_sync_hours: int + last_sync_categories: str | None # raw JSON, parsed on frontend + urls: list[ProviderUrlOut] = [] + + class Config: + from_attributes = True + + +class SyncRequest(BaseModel): + live_category_ids: list[str] | None = None # None = import all + movie_category_ids: list[str] | None = None + series_category_ids: list[str] | None = None + + +def _jellyfin_provider_subq(): + """Subquery returning provider_account_ids used by Jellyfin configs.""" + from ...models.jellyfin import JellyfinConfig + return ( + select(JellyfinConfig.provider_account_id) + .where(JellyfinConfig.provider_account_id.isnot(None)) + .scalar_subquery() + ) + + +@router.get("/", response_model=list[ProviderOut]) +async def list_providers(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute( + select(ProviderAccount) + .options(selectinload(ProviderAccount.extra_urls)) + .where(ProviderAccount.id.notin_(_jellyfin_provider_subq())) + .order_by(ProviderAccount.id) + ) + providers = result.scalars().all() + # Map extra_urls to the urls field expected by ProviderOut + for p in providers: + p.urls = p.extra_urls + return providers + + +async def _sync_epg_source(db: AsyncSession, provider: ProviderAccount) -> None: + """Create or update the auto-managed EPG source for a provider.""" + url = XtreamClient(provider.base_url, provider.username, provider.password).get_xmltv_url() + name = f"EPG - {provider.name}" + result = await db.execute(select(EpgSource).where(EpgSource.name == name)) + source = result.scalar_one_or_none() + if source: + source.url = url + else: + db.add(EpgSource(name=name, url=url, is_active=True)) + await db.commit() + + +async def _delete_epg_source(db: AsyncSession, provider: ProviderAccount) -> None: + name = f"EPG - {provider.name}" + result = await db.execute(select(EpgSource).where(EpgSource.name == name)) + source = result.scalar_one_or_none() + if source: + await db.delete(source) + await db.commit() + + +@router.post("/", response_model=ProviderOut) +async def create_provider(data: ProviderCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + provider = ProviderAccount(**data.model_dump()) + db.add(provider) + await db.commit() + await db.refresh(provider) + await _sync_epg_source(db, provider) + return provider + + +@router.put("/{provider_id}", response_model=ProviderOut) +async def update_provider(provider_id: int, data: ProviderUpdate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) + provider = result.scalar_one_or_none() + if not provider: + raise HTTPException(404, "Provider not found") + provider.name = data.name + provider.base_url = data.base_url + provider.username = data.username + provider.max_connections = data.max_connections + provider.auto_sync_hours = data.auto_sync_hours + if data.password: + provider.password = data.password + await db.commit() + await db.refresh(provider) + await _sync_epg_source(db, provider) + return provider + + +@router.get("/{provider_id}/synced-category-ids") +async def get_synced_category_ids(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + """Returns the category IDs currently synced in DB for this provider (by content type).""" + result = await db.execute( + select(Category.provider_category_id, Category.type) + .where(Category.provider_account_id == provider_id) + ) + rows = result.all() + return { + "live": [r.provider_category_id for r in rows if r.type == ContentType.live], + "movie": [r.provider_category_id for r in rows if r.type == ContentType.movie], + "series": [r.provider_category_id for r in rows if r.type == ContentType.series], + } + + +@router.get("/{provider_id}/available-updates") +async def get_available_updates(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + """Return categories from the provider that are NOT yet synced into the DB.""" + result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) + provider = result.scalar_one_or_none() + if not provider: + raise HTTPException(404, "Provider not found") + + client = XtreamClient(provider.base_url, provider.username, provider.password) + live_task = asyncio.create_task(client.get_live_categories()) + vod_task = asyncio.create_task(client.get_vod_categories()) + series_task = asyncio.create_task(client.get_series_categories()) + live_raw, vod_raw, series_raw = await asyncio.gather(live_task, vod_task, series_task, return_exceptions=True) + + def to_list(raw): + if isinstance(raw, Exception): + return [] + return raw if isinstance(raw, list) else [] + + # Get all category provider_category_ids already in DB for this provider + db_cats = await db.execute( + select(Category.provider_category_id, Category.type) + .where(Category.provider_account_id == provider_id) + ) + synced: set[tuple] = {(str(r.provider_category_id), r.type) for r in db_cats} + + def filter_new(raw, ctype: ContentType): + result = [] + for c in to_list(raw): + cat_id = str(c.get("category_id", "")) + if not cat_id: + continue + if (cat_id, ctype) not in synced: + result.append({"id": cat_id, "name": c.get("category_name", ""), "type": ctype.value}) + return result + + new_live = filter_new(live_raw, ContentType.live) + new_movie = filter_new(vod_raw, ContentType.movie) + new_series = filter_new(series_raw, ContentType.series) + + return { + "provider_id": provider_id, + "provider_name": provider.name, + "new_categories": new_live + new_movie + new_series, + "total": len(new_live) + len(new_movie) + len(new_series), + } + + +@router.delete("/{provider_id}") +async def delete_provider(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) + provider = result.scalar_one_or_none() + if not provider: + raise HTTPException(404, "Provider not found") + await _delete_epg_source(db, provider) + await db.delete(provider) + await db.commit() + return {"ok": True} + + +@router.get("/notifications/all") +async def get_all_notifications(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + """Return new (unsynced + undismissed) categories across all providers (Jellyfin excluded).""" + providers_result = await db.execute( + select(ProviderAccount).where( + ProviderAccount.is_active == True, # noqa: E712 + ProviderAccount.last_sync_at.isnot(None), + ProviderAccount.id.notin_(_jellyfin_provider_subq()), + ) + ) + providers = providers_result.scalars().all() + + # Load all dismissals + dismissals_result = await db.execute(select(NotificationDismissal)) + dismissed: set[tuple] = { + (str(d.provider_id), d.category_id_str, d.category_type) + for d in dismissals_result.scalars().all() + } + + notifications = [] + for provider in providers: + client = XtreamClient(provider.base_url, provider.username, provider.password) + try: + live_raw, vod_raw, series_raw = await asyncio.gather( + client.get_live_categories(), + client.get_vod_categories(), + client.get_series_categories(), + return_exceptions=True, + ) + except Exception: + continue + + db_cats = await db.execute( + select(Category.provider_category_id, Category.type) + .where(Category.provider_account_id == provider.id) + ) + synced: set[tuple] = {(str(r.provider_category_id), r.type) for r in db_cats} + + def collect_new(raw, ctype: ContentType): + if isinstance(raw, Exception): + return [] + items = [] + for c in (raw if isinstance(raw, list) else []): + cat_id = str(c.get("category_id", "")) + if not cat_id: + continue + if (cat_id, ctype) in synced: + continue + if (str(provider.id), cat_id, ctype.value) in dismissed: + continue + items.append({"id": cat_id, "name": c.get("category_name", ""), "type": ctype.value}) + return items + + new_cats = ( + collect_new(live_raw, ContentType.live) + + collect_new(vod_raw, ContentType.movie) + + collect_new(series_raw, ContentType.series) + ) + if new_cats: + notifications.append({ + "provider_id": provider.id, + "provider_name": provider.name, + "categories": new_cats, + }) + + total = sum(len(n["categories"]) for n in notifications) + return {"notifications": notifications, "total": total} + + +@router.get("/notifications/count") +async def get_notifications_count(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + """Fast count of undismissed new categories (checks DB only, no remote API calls).""" + providers_result = await db.execute( + select(ProviderAccount).where(ProviderAccount.is_active == True, ProviderAccount.last_sync_at.isnot(None)) # noqa: E712 + ) + providers = providers_result.scalars().all() + + dismissals_result = await db.execute(select(NotificationDismissal)) + dismissed: set[tuple] = { + (str(d.provider_id), d.category_id_str, d.category_type) + for d in dismissals_result.scalars().all() + } + + # Compare last_sync_categories to what's available + total = 0 + for provider in providers: + if not provider.last_sync_categories: + continue + try: + last = json.loads(provider.last_sync_categories) + except Exception: + continue + # We can't do a fast count without hitting remote — just return 0 here + # Real count comes from get_all_notifications + return {"total": total} + + +class DismissRequest(BaseModel): + provider_id: int + category_id: str + category_type: str + + +@router.post("/notifications/dismiss") +async def dismiss_notification(data: DismissRequest, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + existing = await db.execute( + select(NotificationDismissal).where( + NotificationDismissal.provider_id == data.provider_id, + NotificationDismissal.category_id_str == data.category_id, + NotificationDismissal.category_type == data.category_type, + ) + ) + if not existing.scalar_one_or_none(): + db.add(NotificationDismissal( + provider_id=data.provider_id, + category_id_str=data.category_id, + category_type=data.category_type, + )) + await db.commit() + return {"ok": True} + + +@router.post("/notifications/dismiss-provider/{provider_id}") +async def dismiss_all_for_provider(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + """Dismiss all pending notifications for a specific provider.""" + result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) + provider = result.scalar_one_or_none() + if not provider: + raise HTTPException(404, "Provider not found") + + client = XtreamClient(provider.base_url, provider.username, provider.password) + try: + live_raw, vod_raw, series_raw = await asyncio.gather( + client.get_live_categories(), + client.get_vod_categories(), + client.get_series_categories(), + return_exceptions=True, + ) + except Exception as e: + raise HTTPException(500, f"Failed to fetch provider categories: {e}") + + db_cats = await db.execute( + select(Category.provider_category_id, Category.type) + .where(Category.provider_account_id == provider_id) + ) + synced: set[tuple] = {(str(r.provider_category_id), r.type) for r in db_cats} + + dismissed_result = await db.execute( + select(NotificationDismissal.category_id_str, NotificationDismissal.category_type) + .where(NotificationDismissal.provider_id == provider_id) + ) + already_dismissed: set[tuple] = {(r[0], r[1]) for r in dismissed_result.all()} + + count = 0 + for raw, ctype in [(live_raw, ContentType.live), (vod_raw, ContentType.movie), (series_raw, ContentType.series)]: + if isinstance(raw, Exception): + continue + for c in (raw if isinstance(raw, list) else []): + cat_id = str(c.get("category_id", "")) + if not cat_id or (cat_id, ctype) in synced: + continue + if (cat_id, ctype.value) not in already_dismissed: + db.add(NotificationDismissal( + provider_id=provider_id, + category_id_str=cat_id, + category_type=ctype.value, + )) + count += 1 + + await db.commit() + return {"ok": True, "dismissed": count} + + +@router.get("/{provider_id}/epg-url") +async def get_provider_epg_url(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + """Returns the XMLTV EPG URL for this provider (auto-registered as EPG source).""" + result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) + provider = result.scalar_one_or_none() + if not provider: + raise HTTPException(404, "Provider not found") + url = XtreamClient(provider.base_url, provider.username, provider.password).get_xmltv_url() + return {"url": url} + + +@router.post("/{provider_id}/test") +async def test_provider(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) + provider = result.scalar_one_or_none() + if not provider: + raise HTTPException(404, "Provider not found") + client = XtreamClient(provider.base_url, provider.username, provider.password) + ok = False + try: + info = await client.get_user_info() + user_info = info.get("user_info", {}) + ok = user_info.get("status") == "Active" + exp_ts = user_info.get("exp_date") + if exp_ts: + try: + provider.expiry_date = datetime.fromtimestamp(int(exp_ts), tz=timezone.utc) + await db.commit() + except (ValueError, TypeError): + pass + except Exception as e: + logger.warning(f"Provider test failed: {e}") + return {"connected": ok} + + +@router.get("/{provider_id}/sync/categories") +async def get_provider_categories(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + """Fetch available categories from provider without importing — used for the category picker.""" + result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) + provider = result.scalar_one_or_none() + if not provider: + raise HTTPException(404, "Provider not found") + + client = XtreamClient(provider.base_url, provider.username, provider.password) + + live_task = asyncio.create_task(client.get_live_categories()) + vod_task = asyncio.create_task(client.get_vod_categories()) + series_task = asyncio.create_task(client.get_series_categories()) + + live_raw, vod_raw, series_raw = await asyncio.gather(live_task, vod_task, series_task, return_exceptions=True) + + def to_list(raw): + if isinstance(raw, Exception): + logger.warning(f"Category fetch error: {raw}") + return [] + return [{"id": str(c.get("category_id", "")), "name": c.get("category_name", "")} for c in raw if c.get("category_id")] + + return { + "live": to_list(live_raw), + "movie": to_list(vod_raw), + "series": to_list(series_raw), + } + + +@router.get("/{provider_id}/sync/status") +async def sync_status(provider_id: int, _=Depends(get_current_admin)): + return _sync_status.get(provider_id, {"status": "idle"}) + + +@router.post("/{provider_id}/sync") +async def sync_provider( + provider_id: int, + body: SyncRequest = SyncRequest(), + db: AsyncSession = Depends(get_db), + _=Depends(get_current_admin), +): + result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) + provider = result.scalar_one_or_none() + if not provider: + raise HTTPException(404, "Provider not found") + + if _sync_status.get(provider_id, {}).get("status") == "running": + return {"ok": False, "message": "Sync already in progress", "status": _sync_status[provider_id]} + + _sync_status[provider_id] = {"status": "running", "progress": "Iniciando...", "stats": {}} + asyncio.create_task(_run_sync( + provider_id, + provider.base_url, + provider.username, + provider.password, + selected_live=set(body.live_category_ids) if body.live_category_ids is not None else None, + selected_movie=set(body.movie_category_ids) if body.movie_category_ids is not None else None, + selected_series=set(body.series_category_ids) if body.series_category_ids is not None else None, + )) + return {"ok": True, "message": "Sync started in background", "status_url": f"/api/admin/providers/{provider_id}/sync/status"} + + +async def _run_sync( + provider_id: int, + base_url: str, + username: str, + password: str, + selected_live: set[str] | None = None, + selected_movie: set[str] | None = None, + selected_series: set[str] | None = None, +) -> None: + """ + Efficient sync: fetches ALL streams in one call per type (not per category). + Total HTTP calls: 3 category lists + 3 stream lists = 6 calls regardless of provider size. + selected_* = None means import all; set means only import those category IDs. + """ + client = XtreamClient(base_url, username, password) + stats = { + "live_categories": 0, "live_channels": 0, + "vod_categories": 0, "vod_channels": 0, + "series_categories": 0, "series": 0, + } + + def update_progress(msg: str): + _sync_status[provider_id] = {"status": "running", "progress": msg, "stats": stats.copy()} + logger.info(f"[Sync #{provider_id}] {msg}") + + try: + async with AsyncSessionLocal() as db: + # ── LIVE ────────────────────────────────────────────────── + update_progress("Descargando categorías Live...") + try: + live_cats = await client.get_live_categories() + cat_map_live: dict[str, Category] = {} + for cat_data in live_cats: + cat_id_str = str(cat_data.get("category_id", "")) + if selected_live is not None and cat_id_str not in selected_live: + continue + cat = await _upsert_category(db, cat_data, ContentType.live, provider_id) + cat_map_live[cat_id_str] = cat + stats["live_categories"] += 1 + await db.flush() + + update_progress(f"Descargando canales Live ({stats['live_categories']} cats)...") + all_live = await client.get_live_streams() + for ch_data in all_live: + cat_id_str = str(ch_data.get("category_id", "")) + if cat_id_str not in cat_map_live: + continue # category not selected or not loaded + cat = cat_map_live.get(cat_id_str) + await _upsert_channel(db, ch_data, cat, ContentType.live, provider_id, client) + stats["live_channels"] += 1 + if stats["live_channels"] % 500 == 0: + update_progress(f"Live: {stats['live_channels']} canales procesados...") + await db.flush() + + await db.flush() + except Exception as e: + logger.error(f"[Sync #{provider_id}] Live error: {e}", exc_info=True) + + # ── VOD ─────────────────────────────────────────────────── + update_progress("Descargando categorías VOD...") + try: + vod_cats = await client.get_vod_categories() + cat_map_vod: dict[str, Category] = {} + for cat_data in vod_cats: + cat_id_str = str(cat_data.get("category_id", "")) + if selected_movie is not None and cat_id_str not in selected_movie: + continue + cat = await _upsert_category(db, cat_data, ContentType.movie, provider_id) + cat_map_vod[cat_id_str] = cat + stats["vod_categories"] += 1 + await db.flush() + + update_progress(f"Descargando películas VOD ({stats['vod_categories']} cats)...") + all_vod = await client.get_vod_streams() + for ch_data in all_vod: + cat_id_str = str(ch_data.get("category_id", "")) + if cat_id_str not in cat_map_vod: + continue + cat = cat_map_vod.get(cat_id_str) + await _upsert_channel(db, ch_data, cat, ContentType.movie, provider_id, client) + stats["vod_channels"] += 1 + if stats["vod_channels"] % 500 == 0: + update_progress(f"VOD: {stats['vod_channels']} canales procesados...") + await db.flush() + + await db.flush() + except Exception as e: + logger.error(f"[Sync #{provider_id}] VOD error: {e}", exc_info=True) + + # ── SERIES ──────────────────────────────────────────────── + update_progress("Descargando categorías Series...") + try: + series_cats = await client.get_series_categories() + cat_map_series: dict[str, Category] = {} + for cat_data in series_cats: + cat_id_str = str(cat_data.get("category_id", "")) + if selected_series is not None and cat_id_str not in selected_series: + continue + cat = await _upsert_category(db, cat_data, ContentType.series, provider_id) + cat_map_series[cat_id_str] = cat + stats["series_categories"] += 1 + await db.flush() + + update_progress(f"Descargando series ({stats['series_categories']} cats)...") + all_series = await client.get_series() + for s_data in all_series: + cat_id_str = str(s_data.get("category_id", "")) + if cat_id_str not in cat_map_series: + continue + cat = cat_map_series.get(cat_id_str) + await _upsert_channel(db, s_data, cat, ContentType.series, provider_id, client) + stats["series"] += 1 + if stats["series"] % 500 == 0: + update_progress(f"Series: {stats['series']} procesadas...") + await db.flush() + + await db.flush() + except Exception as e: + logger.error(f"[Sync #{provider_id}] Series error: {e}", exc_info=True) + + # ── FINAL COMMIT ────────────────────────────────────────── + update_progress("Guardando en base de datos...") + result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) + provider = result.scalar_one_or_none() + if provider: + provider.last_sync_at = datetime.now(timezone.utc) + provider.last_sync_categories = json.dumps({ + "live": list(selected_live) if selected_live is not None else None, + "movie": list(selected_movie) if selected_movie is not None else None, + "series": list(selected_series) if selected_series is not None else None, + }) + await db.commit() + + _sync_status[provider_id] = {"status": "done", "progress": "Sync completado", "stats": stats} + logger.info(f"[Sync #{provider_id}] Completed: {stats}") + + # Auto-update EPG source and trigger background refresh + async with AsyncSessionLocal() as epg_db: + prov_result = await epg_db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) + prov = prov_result.scalar_one_or_none() + if prov: + await _sync_epg_source(epg_db, prov) + await refresh_epg(epg_db) + + except Exception as e: + logger.error(f"[Sync #{provider_id}] Fatal error: {e}", exc_info=True) + _sync_status[provider_id] = {"status": "error", "progress": str(e), "stats": stats} + + +# ── Provider URL management ──────────────────────────────────────────── + +class ProviderUrlCreate(BaseModel): + url: str + priority: int = 10 + is_active: bool = True + + +@router.get("/{provider_id}/urls", response_model=list[ProviderUrlOut]) +async def list_provider_urls(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute( + select(ProviderUrl) + .where(ProviderUrl.provider_account_id == provider_id) + .order_by(ProviderUrl.priority) + ) + return result.scalars().all() + + +@router.post("/{provider_id}/urls", response_model=ProviderUrlOut) +async def add_provider_url(provider_id: int, data: ProviderUrlCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + pu = ProviderUrl(provider_account_id=provider_id, **data.model_dump()) + db.add(pu) + await db.commit() + await db.refresh(pu) + return pu + + +@router.put("/{provider_id}/urls/{url_id}", response_model=ProviderUrlOut) +async def update_provider_url(provider_id: int, url_id: int, data: ProviderUrlCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute( + select(ProviderUrl).where(ProviderUrl.id == url_id, ProviderUrl.provider_account_id == provider_id) + ) + pu = result.scalar_one_or_none() + if not pu: + raise HTTPException(404, "URL not found") + pu.url = data.url + pu.priority = data.priority + pu.is_active = data.is_active + await db.commit() + await db.refresh(pu) + return pu + + +@router.delete("/{provider_id}/urls/{url_id}") +async def delete_provider_url(provider_id: int, url_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute( + select(ProviderUrl).where(ProviderUrl.id == url_id, ProviderUrl.provider_account_id == provider_id) + ) + pu = result.scalar_one_or_none() + if not pu: + raise HTTPException(404, "URL not found") + await db.delete(pu) + await db.commit() + return {"ok": True} + + +@router.post("/{provider_id}/urls/check-all") +async def check_all_provider_urls(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + from ...core.health_checker import check_provider_urls + result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) + provider = result.scalar_one_or_none() + if not provider: + raise HTTPException(404, "Provider not found") + results = await check_provider_urls(provider_id) + return results + + +async def _upsert_category(db: AsyncSession, cat_data: dict, ctype: ContentType, provider_id: int) -> Category: + provider_cat_id = str(cat_data.get("category_id", "")) + cat_name = cat_data.get("category_name", "Unknown") + + # First: check if a category with the same name+type already exists (any provider) + # This merges duplicate categories from multiple providers automatically + result = await db.execute( + select(Category).where( + Category.name == cat_name, + Category.type == ctype, + ) + ) + cat = result.scalar_one_or_none() + + if not cat: + # Check by provider+id (in case name changed) + result2 = await db.execute( + select(Category).where( + Category.provider_category_id == provider_cat_id, + Category.provider_account_id == provider_id, + Category.type == ctype, + ) + ) + cat = result2.scalar_one_or_none() + + if not cat: + cat = Category( + name=cat_name, + type=ctype, + provider_category_id=provider_cat_id, + provider_account_id=provider_id, + ) + db.add(cat) + await db.flush() + else: + cat.name = cat_name + return cat + + +async def _upsert_channel( + db: AsyncSession, + ch_data: dict, + category: Category | None, + ctype: ContentType, + provider_id: int, + client: XtreamClient, +) -> Channel | None: + stream_id = str(ch_data.get("stream_id") or ch_data.get("series_id", "")) + if not stream_id: + return None + + result = await db.execute( + select(Channel).where( + Channel.stream_id_at_provider == stream_id, + Channel.type == ctype, + ) + ) + channel = result.scalar_one_or_none() + + name = ch_data.get("name") or ch_data.get("title", "Unknown") or "Unknown" + tvg_id = ch_data.get("epg_channel_id") or ch_data.get("tvg_id") or None + logo = ch_data.get("stream_icon") or ch_data.get("cover") or None + # Truncate logo to avoid DB column limit (base64 images from some providers) + if logo and len(logo) > 900: + logo = None + cat_id = category.id if category else None + + if not channel: + channel = Channel( + name=name, + tvg_id=tvg_id, + tvg_logo=logo, + stream_id_at_provider=stream_id, + category_id=cat_id, + type=ctype, + ) + db.add(channel) + await db.flush() + else: + channel.name = name + channel.tvg_id = tvg_id + channel.tvg_logo = logo + # Only update category if channel has none yet (first-provider-wins prevents + # cross-provider syncs from breaking existing catalog assignments) + if cat_id and not channel.category_id: + channel.category_id = cat_id + + # Build stream URL + container_ext = ch_data.get("container_extension", "ts") or "ts" + if ctype == ContentType.live: + url = client.build_live_stream_url(stream_id) + elif ctype == ContentType.movie: + url = client.build_vod_stream_url(stream_id, container_ext) + else: + url = client.build_series_stream_url(stream_id, container_ext) + + # Upsert provider map + result = await db.execute( + select(ChannelProviderMap).where( + ChannelProviderMap.channel_id == channel.id, + ChannelProviderMap.provider_account_id == provider_id, + ) + ) + pmap = result.scalar_one_or_none() + if not pmap: + pmap = ChannelProviderMap( + channel_id=channel.id, + provider_account_id=provider_id, + stream_url=url, + ) + db.add(pmap) + else: + pmap.stream_url = url + + return channel diff --git a/backend/api/admin/settings.py b/backend/api/admin/settings.py new file mode 100644 index 0000000..99fa41d --- /dev/null +++ b/backend/api/admin/settings.py @@ -0,0 +1,38 @@ +"""Admin settings — blocked image upload/management.""" +import os + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from fastapi.responses import FileResponse + +from ..auth import get_current_admin + +router = APIRouter(prefix="/settings", tags=["settings"]) + +BLOCKED_IMAGE_PATH = "/opt/kirastream/backend/static/blocked_image.jpg" +MAX_SIZE = 10 * 1024 * 1024 # 10 MB + + +@router.get("/blocked-image") +async def get_blocked_image(_=Depends(get_current_admin)): + if not os.path.exists(BLOCKED_IMAGE_PATH): + raise HTTPException(404, "No blocked image configured") + return FileResponse(BLOCKED_IMAGE_PATH, media_type="image/jpeg") + + +@router.post("/blocked-image", status_code=201) +async def upload_blocked_image(file: UploadFile = File(...), _=Depends(get_current_admin)): + if file.content_type not in ("image/jpeg", "image/png", "image/webp", "image/gif"): + raise HTTPException(400, "Only JPEG, PNG, WebP or GIF images allowed") + data = await file.read() + if len(data) > MAX_SIZE: + raise HTTPException(413, "Image too large (max 10 MB)") + os.makedirs(os.path.dirname(BLOCKED_IMAGE_PATH), exist_ok=True) + with open(BLOCKED_IMAGE_PATH, "wb") as f: + f.write(data) + return {"ok": True, "size": len(data)} + + +@router.delete("/blocked-image", status_code=204) +async def delete_blocked_image(_=Depends(get_current_admin)): + if os.path.exists(BLOCKED_IMAGE_PATH): + os.remove(BLOCKED_IMAGE_PATH) diff --git a/backend/api/admin/users.py b/backend/api/admin/users.py new file mode 100644 index 0000000..ff6e5ed --- /dev/null +++ b/backend/api/admin/users.py @@ -0,0 +1,267 @@ +"""Admin API: user management.""" +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from pydantic import BaseModel + +from ...database import get_db +from ...models.user import User, UserCatalogEntry, UserCustomCatalogEntry, UserProviderPreference +from ...models.channel import Category +from ...models.provider import ProviderAccount +from ...models.custom_category import CustomCategory +from ..auth import get_current_admin + +router = APIRouter(prefix="/users", tags=["users"]) + + +class UserCreate(BaseModel): + username: str + password: str + max_connections: int = 1 + expiry_date: datetime | None = None + is_active: bool = True + is_priority: bool = False + + +class UserUpdate(BaseModel): + password: str | None = None + max_connections: int | None = None + expiry_date: datetime | None = None + is_active: bool | None = None + is_priority: bool | None = None + + +class UserOut(BaseModel): + id: int + username: str + max_connections: int + expiry_date: datetime | None + is_active: bool + is_priority: bool + created_at: datetime + + class Config: + from_attributes = True + + +@router.get("/", response_model=list[UserOut]) +async def list_users(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute(select(User).order_by(User.id)) + return result.scalars().all() + + +@router.post("/", response_model=UserOut) +async def create_user(data: UserCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + existing = await db.execute(select(User).where(User.username == data.username)) + if existing.scalar_one_or_none(): + raise HTTPException(400, f"Username '{data.username}' already exists") + user = User( + username=data.username, + max_connections=data.max_connections, + expiry_date=data.expiry_date, + is_active=data.is_active, + is_priority=data.is_priority, + password_hash="", + ) + user.set_password(data.password) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +@router.put("/{user_id}", response_model=UserOut) +async def update_user(user_id: int, data: UserUpdate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(404, "User not found") + if data.password is not None: + user.set_password(data.password) + if data.max_connections is not None: + user.max_connections = data.max_connections + if data.expiry_date is not None: + user.expiry_date = data.expiry_date + if data.is_active is not None: + user.is_active = data.is_active + if data.is_priority is not None: + user.is_priority = data.is_priority + await db.commit() + await db.refresh(user) + return user + + +@router.delete("/{user_id}") +async def delete_user(user_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(404, "User not found") + await db.delete(user) + await db.commit() + return {"ok": True} + + +@router.get("/{user_id}/catalog") +async def get_user_catalog(user_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute( + select(UserCatalogEntry) + .options(selectinload(UserCatalogEntry.category)) + .where(UserCatalogEntry.user_id == user_id) + ) + entries = result.scalars().all() + return [ + {"id": e.id, "category_id": e.category_id, "category_name": e.category.name, "type": e.category.type} + for e in entries + ] + + +@router.post("/{user_id}/catalog") +async def assign_categories(user_id: int, category_ids: list[int], db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute(select(User).where(User.id == user_id)) + if not result.scalar_one_or_none(): + raise HTTPException(404, "User not found") + + # Remove existing entries + existing = await db.execute(select(UserCatalogEntry).where(UserCatalogEntry.user_id == user_id)) + for entry in existing.scalars().all(): + await db.delete(entry) + + # Add new entries + added = 0 + for cat_id in category_ids: + cat_result = await db.execute(select(Category).where(Category.id == cat_id)) + if cat_result.scalar_one_or_none(): + db.add(UserCatalogEntry(user_id=user_id, category_id=cat_id)) + added += 1 + + await db.commit() + return {"ok": True, "assigned": added} + + +@router.delete("/{user_id}/catalog/{category_id}") +async def remove_category(user_id: int, category_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute( + select(UserCatalogEntry).where( + UserCatalogEntry.user_id == user_id, + UserCatalogEntry.category_id == category_id, + ) + ) + entry = result.scalar_one_or_none() + if not entry: + raise HTTPException(404, "Catalog entry not found") + await db.delete(entry) + await db.commit() + return {"ok": True} + + +@router.get("/{user_id}/custom-catalog") +async def get_user_custom_catalog(user_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute( + select(UserCustomCatalogEntry.custom_category_id).where(UserCustomCatalogEntry.user_id == user_id) + ) + return [row[0] for row in result.all()] + + +@router.post("/{user_id}/custom-catalog") +async def assign_custom_categories(user_id: int, custom_category_ids: list[int], db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute(select(User).where(User.id == user_id)) + if not result.scalar_one_or_none(): + raise HTTPException(404, "User not found") + + existing = await db.execute(select(UserCustomCatalogEntry).where(UserCustomCatalogEntry.user_id == user_id)) + for entry in existing.scalars().all(): + await db.delete(entry) + + added = 0 + for cc_id in custom_category_ids: + cc_result = await db.execute(select(CustomCategory).where(CustomCategory.id == cc_id)) + if cc_result.scalar_one_or_none(): + db.add(UserCustomCatalogEntry(user_id=user_id, custom_category_id=cc_id)) + added += 1 + + await db.commit() + return {"ok": True, "assigned": added} + + +# --------------------------------------------------------------------------- +# Provider preferences +# --------------------------------------------------------------------------- + +class ProviderPrefIn(BaseModel): + provider_account_id: int + priority: int = 0 + + +@router.get("/{user_id}/providers") +async def get_user_provider_prefs(user_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute( + select(UserProviderPreference) + .options(selectinload(UserProviderPreference.provider_account)) + .where(UserProviderPreference.user_id == user_id) + .order_by(UserProviderPreference.priority) + ) + prefs = result.scalars().all() + return [ + { + "id": p.id, + "provider_account_id": p.provider_account_id, + "provider_name": p.provider_account.name, + "priority": p.priority, + } + for p in prefs + ] + + +@router.post("/{user_id}/providers") +async def add_user_provider_pref( + user_id: int, data: ProviderPrefIn, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin) +): + user_result = await db.execute(select(User).where(User.id == user_id)) + if not user_result.scalar_one_or_none(): + raise HTTPException(404, "User not found") + + provider_result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == data.provider_account_id)) + if not provider_result.scalar_one_or_none(): + raise HTTPException(404, "Provider account not found") + + # Upsert + existing = await db.execute( + select(UserProviderPreference).where( + UserProviderPreference.user_id == user_id, + UserProviderPreference.provider_account_id == data.provider_account_id, + ) + ) + pref = existing.scalar_one_or_none() + if pref: + pref.priority = data.priority + else: + pref = UserProviderPreference( + user_id=user_id, + provider_account_id=data.provider_account_id, + priority=data.priority, + ) + db.add(pref) + + await db.commit() + return {"ok": True} + + +@router.delete("/{user_id}/providers/{provider_account_id}") +async def remove_user_provider_pref( + user_id: int, provider_account_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin) +): + result = await db.execute( + select(UserProviderPreference).where( + UserProviderPreference.user_id == user_id, + UserProviderPreference.provider_account_id == provider_account_id, + ) + ) + pref = result.scalar_one_or_none() + if not pref: + raise HTTPException(404, "Preference not found") + await db.delete(pref) + await db.commit() + return {"ok": True} diff --git a/backend/api/auth.py b/backend/api/auth.py new file mode 100644 index 0000000..d158e78 --- /dev/null +++ b/backend/api/auth.py @@ -0,0 +1,47 @@ +"""JWT authentication for the admin panel.""" +from datetime import datetime, timedelta, timezone +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from jose import JWTError, jwt +from pydantic import BaseModel + +from ..config import settings + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/auth/token") + + +class Token(BaseModel): + access_token: str + token_type: str + + +def create_access_token(data: dict) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes) + to_encode["exp"] = expire + return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) + + +async def get_current_admin(token: str = Depends(oauth2_scheme)) -> str: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except JWTError: + raise credentials_exception + if username != settings.admin_username: + raise credentials_exception + return username + + +async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> Token: + if form_data.username != settings.admin_username or form_data.password != settings.admin_password: + raise HTTPException(status_code=400, detail="Incorrect username or password") + token = create_access_token({"sub": form_data.username}) + return Token(access_token=token, token_type="bearer") diff --git a/backend/api/xtream.py b/backend/api/xtream.py new file mode 100644 index 0000000..b286f1d --- /dev/null +++ b/backend/api/xtream.py @@ -0,0 +1,928 @@ +"""Xtream Codes compatible API — used by IPTV player apps.""" +import asyncio +import logging +import time +from datetime import datetime, timezone +from typing import AsyncIterator + +import aiohttp +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi.responses import StreamingResponse, PlainTextResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from ..config import settings +from ..core.catalog import get_user_categories, get_user_channels, get_channel_provider_maps, can_user_access_channel, get_user_preferred_provider_ids +from ..core.epg_manager import filter_epg_for_channels +from ..core.pool import pool, NoSlotsAvailableError, MaxConnectionsExceededError +from ..core.xtream_client import XtreamClient +from ..core.vod_tracker import vod_tracker, VodSession +from ..database import get_db, AsyncSessionLocal +from ..models.channel import Channel, ContentType +from ..models.custom_category import CustomCategory, CustomCategoryItem +from ..models.jellyfin import JellyfinConfig, JellyfinItem +from ..models.log import ConnectionLog +from ..models.provider import ProviderAccount +from ..models.user import User, UserCustomCatalogEntry + +CC_PREFIX = "cc_" # prefix for custom category IDs in Xtream protocol +BLOCKED_IMAGE_PATH = "/opt/kirastream/backend/static/blocked_image.jpg" + +# episode_id → provider_account_id; populated by get_series_info calls so +# series_stream can route episode requests without storing episodes in the DB +_episode_provider_cache: dict[str, int] = {} + +# jellyfin fake_ep_id → direct-play URL; populated in _get_jellyfin_series_info +_jellyfin_episode_cache: dict[str, str] = {} + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["xtream"]) + +SERVER_URL_PLACEHOLDER = "{server_url}" + + +def get_client_ip(request: Request) -> str: + xff = request.headers.get("X-Forwarded-For", "") + if xff: + return xff.split(",")[0].strip() + xri = request.headers.get("X-Real-IP", "") + if xri: + return xri + return request.client.host if request.client else "unknown" + + +async def _create_log( + user_id: int, + username: str, + channel_id: int | None, + channel_name: str | None, + content_type: str, + stream_id: str | None, + client_ip: str, +) -> int: + async with AsyncSessionLocal() as db: + log = ConnectionLog( + user_id=user_id, + username=username, + channel_id=channel_id, + channel_name=channel_name, + content_type=content_type, + stream_id=stream_id, + client_ip=client_ip, + ) + db.add(log) + await db.commit() + await db.refresh(log) + return log.id + + +async def _finish_log(log_id: int, bytes_transferred: int, started: float) -> None: + ended = datetime.now(timezone.utc) + duration = int(time.time() - started) + async with AsyncSessionLocal() as db: + result = await db.execute(select(ConnectionLog).where(ConnectionLog.id == log_id)) + log = result.scalar_one_or_none() + if log: + log.ended_at = ended + log.duration_seconds = duration + log.bytes_transferred = bytes_transferred + await db.commit() + + +async def _stream_blocked_image() -> StreamingResponse: + """Return a continuous MPEG-TS video stream of the blocked image via FFmpeg.""" + import os + + if not os.path.exists(BLOCKED_IMAGE_PATH): + raise HTTPException(503, "No provider slots available — try again later") + + async def generate(): + proc = await asyncio.create_subprocess_exec( + "ffmpeg", + "-re", "-loop", "1", "-i", BLOCKED_IMAGE_PATH, + "-c:v", "libx264", "-preset", "ultrafast", "-tune", "stillimage", + "-b:v", "400k", "-pix_fmt", "yuv420p", + "-vf", "scale=1280:720", + "-f", "mpegts", "pipe:1", + "-loglevel", "quiet", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + try: + while True: + chunk = await proc.stdout.read(65536) + if not chunk: + break + yield chunk + finally: + try: + proc.terminate() + await asyncio.wait_for(proc.wait(), timeout=2.0) + except (asyncio.TimeoutError, ProcessLookupError): + try: + proc.kill() + except ProcessLookupError: + pass + + return StreamingResponse( + generate(), + media_type="video/mp2t", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +async def _get_user_custom_cat_ids(db: AsyncSession, user_id: int) -> set[int] | None: + """Returns set of custom category IDs for the user, or None if unrestricted (show all).""" + result = await db.execute( + select(UserCustomCatalogEntry.custom_category_id) + .where(UserCustomCatalogEntry.user_id == user_id) + ) + rows = result.scalars().all() + return set(rows) if rows else None # None means "no restriction — show all" + + +async def _can_access_via_custom_category(db: AsyncSession, channel_id: int, user_id: int) -> bool: + allowed_ids = await _get_user_custom_cat_ids(db, user_id) + q = select(CustomCategoryItem).where(CustomCategoryItem.channel_id == channel_id) + if allowed_ids is not None: + q = q.where(CustomCategoryItem.custom_category_id.in_(allowed_ids)) + result = await db.execute(q.limit(1)) + return result.scalar_one_or_none() is not None + + +async def _get_custom_category_channels(db: AsyncSession, cc_id: int) -> list[Channel]: + result = await db.execute( + select(Channel) + .join(CustomCategoryItem, CustomCategoryItem.channel_id == Channel.id) + .options(selectinload(Channel.provider_maps)) + .where(CustomCategoryItem.custom_category_id == cc_id, Channel.is_active == True) # noqa: E712 + .order_by(CustomCategoryItem.position, CustomCategoryItem.id) + ) + return list(result.scalars().all()) + + +# --------------------------------------------------------------------------- +# Auth helper +# --------------------------------------------------------------------------- + +async def authenticate_user(username: str, password: str, db: AsyncSession) -> User: + result = await db.execute(select(User).where(User.username == username, User.is_active == True)) # noqa: E712 + user = result.scalar_one_or_none() + if not user: + raise HTTPException(403, "Invalid credentials") + if not user.verify_password(password): + raise HTTPException(403, "Invalid credentials") + if user.expiry_date and user.expiry_date < datetime.now(timezone.utc): + raise HTTPException(403, "Account expired") + return user + + +def server_url(request: Request) -> str: + return f"{request.url.scheme}://{request.url.netloc}" + + +# --------------------------------------------------------------------------- +# player_api.php (main Xtream Codes endpoint) +# --------------------------------------------------------------------------- + +@router.get("/player_api.php") +async def player_api( + request: Request, + username: str = "", + password: str = "", + action: str = "", + category_id: str | None = None, + stream_id: str | None = None, + series_id: str | None = None, + vod_id: str | None = None, + db: AsyncSession = Depends(get_db), +): + if not username or not password: + return {"user_info": {"status": "Disabled"}, "server_info": {}} + + user = await authenticate_user(username, password, db) + base = server_url(request) + + if not action: + return await _user_info(user, password, base, db) + + if action in ("user_info", "get_account_info"): + return await _user_info(user, password, base, db) + + if action == "get_live_categories": + return await _get_live_categories(user, db) + + if action == "get_live_streams": + return await _get_live_streams(user, db, category_id, base) + + if action == "get_vod_categories": + return await _get_categories(user, db, ContentType.movie) + + if action == "get_vod_streams": + return await _get_channels(user, db, ContentType.movie, category_id, base) + + if action == "get_series_categories": + return await _get_categories(user, db, ContentType.series) + + if action == "get_series": + return await _get_channels(user, db, ContentType.series, category_id, base) + + if action == "get_series_info" and series_id: + return await _get_series_info(series_id, user, db) + + if action == "get_vod_info" and vod_id: + return await _get_vod_info(vod_id, user, db) + + if action == "get_short_epg" and stream_id: + return {"epg_listings": []} + + return {} + + +# --------------------------------------------------------------------------- +# Playlist endpoint +# --------------------------------------------------------------------------- + +@router.get("/get.php") +async def get_playlist( + request: Request, + username: str = "", + password: str = "", + type: str = "m3u_plus", + output: str = "ts", + db: AsyncSession = Depends(get_db), +): + user = await authenticate_user(username, password, db) + base = server_url(request) + + channels = await get_user_channels(db, user) + lines = ["#EXTM3U"] + for ch in channels: + cat_name = ch.category.name if ch.category else "" + tvg_id = ch.tvg_id or "" + logo = ch.tvg_logo or "" + if ch.type == ContentType.live: + url = f"{base}/{username}/{password}/{ch.stream_id_at_provider}" + elif ch.type == ContentType.movie: + ext = "mkv" + url = f"{base}/movie/{username}/{password}/{ch.stream_id_at_provider}.{ext}" + else: + url = f"{base}/series/{username}/{password}/{ch.stream_id_at_provider}.mkv" + + lines.append( + f'#EXTINF:-1 tvg-id="{tvg_id}" tvg-logo="{logo}" group-title="{cat_name}",{ch.name}' + ) + lines.append(url) + + return PlainTextResponse("\n".join(lines), media_type="application/x-mpegurl") + + +# --------------------------------------------------------------------------- +# EPG endpoint +# --------------------------------------------------------------------------- + +@router.get("/xmltv.php") +async def xmltv( + username: str = "", + password: str = "", + db: AsyncSession = Depends(get_db), +): + user = await authenticate_user(username, password, db) + channels = await get_user_channels(db, user) + tvg_ids = {ch.tvg_id for ch in channels if ch.tvg_id} + epg_data = filter_epg_for_channels(tvg_ids) + return Response(content=epg_data, media_type="application/xml") + + +# --------------------------------------------------------------------------- +# Live stream proxy /{username}/{password}/{stream_id} +# Also handles /live/{username}/{password}/{stream_id} used by Smarters/IPTVnator +# --------------------------------------------------------------------------- + +@router.get("/live/{username}/{password}/{stream_id}") +@router.get("/{username}/{password}/{stream_id}") +async def live_stream( + username: str, + password: str, + stream_id: str, + request: Request, + db: AsyncSession = Depends(get_db), +): + # Strip extension if present (e.g. "123456.ts" -> "123456") + stream_id_clean = stream_id.rsplit(".", 1)[0] if "." in stream_id else stream_id + + user = await authenticate_user(username, password, db) + + # Resolve channel by stream_id_at_provider + result = await db.execute( + select(Channel) + .options(selectinload(Channel.provider_maps)) + .where(Channel.stream_id_at_provider == stream_id_clean, Channel.type == ContentType.live, Channel.is_active == True) # noqa: E712 + ) + channel = result.scalar_one_or_none() + if not channel: + raise HTTPException(404, "Channel not found") + + # Check user can access this channel (regular catalog or custom category) + if not await can_user_access_channel(db, user, channel.id): + if not await _can_access_via_custom_category(db, channel.id, user.id): + raise HTTPException(403, "Channel not in your catalog") + + provider_maps = await get_channel_provider_maps(db, channel.id) + if not provider_maps: + raise HTTPException(503, "No provider available for this channel") + + preferred_ids = await get_user_preferred_provider_ids(db, user) + + try: + handle = await pool.acquire( + channel_id=channel.id, + stream_url_resolver=None, + provider_maps=provider_maps, + user_id=str(user.id), + user_max_conns=user.max_connections, + is_priority=user.is_priority, + preferred_provider_ids=preferred_ids, + username=user.username, + channel_name=channel.name, + ) + except MaxConnectionsExceededError: + return await _stream_blocked_image() + except NoSlotsAvailableError: + raise HTTPException(503, "No provider slots available — try again later") + + client_ip = get_client_ip(request) + log_id = await _create_log(user.id, user.username, channel.id, channel.name, "live", stream_id_clean, client_ip) + stream_started = time.time() + + async def stream_generator() -> AsyncIterator[bytes]: + bytes_sent = 0 + try: + async for chunk in handle.read(): + bytes_sent += len(chunk) + yield chunk + finally: + try: + await asyncio.shield(pool.release(channel.id, handle.client_id, str(user.id))) + except asyncio.CancelledError: + pass + asyncio.create_task(_finish_log(log_id, bytes_sent, stream_started)) + + return StreamingResponse( + stream_generator(), + media_type="video/mp2t", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + +# --------------------------------------------------------------------------- +# VOD proxy /movie/{username}/{password}/{stream_id}.ext +# --------------------------------------------------------------------------- + +@router.get("/movie/{username}/{password}/{stream_id}") +async def vod_stream( + username: str, + password: str, + stream_id: str, + request: Request, + db: AsyncSession = Depends(get_db), +): + # Parse extension + parts = stream_id.rsplit(".", 1) + sid = parts[0] + ext = parts[1] if len(parts) > 1 else "mkv" + + user = await authenticate_user(username, password, db) + + result = await db.execute( + select(Channel) + .options(selectinload(Channel.provider_maps)) + .where(Channel.stream_id_at_provider == sid, Channel.type == ContentType.movie) + ) + channel = result.scalar_one_or_none() + # Fallback: Jellyfin channels have non-numeric stream_id_at_provider so the app + # receives ch.id (DB int) as the stream_id — look it up by primary key. + if not channel: + try: + ch_id = int(sid) + fb = await db.execute( + select(Channel) + .options(selectinload(Channel.provider_maps)) + .where(Channel.id == ch_id, Channel.type == ContentType.movie) + ) + channel = fb.scalar_one_or_none() + except (ValueError, TypeError): + pass + if not channel: + raise HTTPException(404, "VOD not found") + + if not await can_user_access_channel(db, user, channel.id): + if not await _can_access_via_custom_category(db, channel.id, user.id): + raise HTTPException(403, "Not in your catalog") + + if not channel.provider_maps: + raise HTTPException(503, "No provider available") + + pmap = channel.provider_maps[0] + prov_res = await db.execute(select(ProviderAccount).where(ProviderAccount.id == pmap.provider_account_id)) + provider = prov_res.scalar_one_or_none() + vod_session = vod_tracker.start( + user_id=str(user.id), + username=user.username, + channel_id=channel.id, + channel_name=channel.name, + provider_account_id=pmap.provider_account_id, + provider_name=provider.name if provider else f"#{pmap.provider_account_id}", + stream_type="movie", + stream_url=pmap.stream_url, + user_max_connections=user.max_connections, + ) + client_ip = get_client_ip(request) + log_id = await _create_log(user.id, user.username, channel.id, channel.name, "movie", sid, client_ip) + return await _proxy_vod(pmap.stream_url, request, vod_session=vod_session, log_id=log_id) + + +# --------------------------------------------------------------------------- +# Series proxy /series/{username}/{password}/{stream_id}.ext +# --------------------------------------------------------------------------- + +@router.get("/series/{username}/{password}/{stream_id}") +async def series_stream( + username: str, + password: str, + stream_id: str, + request: Request, + db: AsyncSession = Depends(get_db), +): + parts = stream_id.rsplit(".", 1) + sid = parts[0] + ext = parts[1] if len(parts) > 1 else "mkv" + + user = await authenticate_user(username, password, db) + + # First: try the episode as a stored channel (future: when episodes are synced) + result = await db.execute( + select(Channel) + .options(selectinload(Channel.provider_maps)) + .where(Channel.stream_id_at_provider == sid, Channel.type == ContentType.series) + ) + channel = result.scalar_one_or_none() + client_ip = get_client_ip(request) + + if channel and channel.provider_maps: + if not await can_user_access_channel(db, user, channel.id): + if not await _can_access_via_custom_category(db, channel.id, user.id): + raise HTTPException(403, "Not in your catalog") + pmap = channel.provider_maps[0] + prov_res = await db.execute(select(ProviderAccount).where(ProviderAccount.id == pmap.provider_account_id)) + provider = prov_res.scalar_one_or_none() + vod_session = vod_tracker.start( + user_id=str(user.id), username=user.username, + channel_id=channel.id, channel_name=channel.name, + provider_account_id=pmap.provider_account_id, + provider_name=provider.name if provider else f"#{pmap.provider_account_id}", + stream_type="series", + stream_url=pmap.stream_url, + user_max_connections=user.max_connections, + ) + log_id = await _create_log(user.id, user.username, channel.id, channel.name, "series", sid, client_ip) + return await _proxy_vod(pmap.stream_url, request, vod_session=vod_session, log_id=log_id) + + # Second: episode_id was cached when the app called get_series_info + provider_id = _episode_provider_cache.get(sid) + if provider_id: + prov_result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) + provider = prov_result.scalar_one_or_none() + if provider: + client = XtreamClient(provider.base_url, provider.username, provider.password) + url = client.build_series_stream_url(sid, ext) + vod_session = vod_tracker.start( + user_id=str(user.id), username=user.username, + channel_id=0, channel_name=f"Episode {sid}", + provider_account_id=provider_id, + provider_name=provider.name, + stream_type="series", + stream_url=url, + user_max_connections=user.max_connections, + ) + log_id = await _create_log(user.id, user.username, 0, f"Episode {sid}", "series", sid, client_ip) + return await _proxy_vod(url, request, vod_session=vod_session, log_id=log_id) + + # Third: check if it's a cached Jellyfin episode (fake_id → direct-play URL) + jelly_url = _jellyfin_episode_cache.get(sid) + if jelly_url: + vod_session = vod_tracker.start( + user_id=str(user.id), username=user.username, + channel_id=0, channel_name=f"Episode {sid}", + provider_account_id=0, + provider_name="Jellyfin", + stream_type="series", + stream_url=jelly_url, + user_max_connections=user.max_connections, + ) + log_id = await _create_log(user.id, user.username, 0, f"Episode {sid}", "series", sid, client_ip) + return await _proxy_vod(jelly_url, request, vod_session=vod_session, log_id=log_id) + + raise HTTPException(404, "Episode not found — open the series detail first to load episode info") + + +async def _proxy_vod( + upstream_url: str, + request: Request, + vod_session: VodSession | None = None, + log_id: int | None = None, +) -> StreamingResponse: + """Transparent proxy for VOD/series — forwards status, Content-Type, and range headers.""" + req_headers = {} + if "Range" in request.headers: + req_headers["Range"] = request.headers["Range"] + + timeout = aiohttp.ClientTimeout(connect=10, sock_read=300) + http_session = aiohttp.ClientSession(timeout=timeout) + if vod_session: + vod_session._http_session = http_session + try: + resp = await http_session.get(upstream_url, headers=req_headers, ssl=False) + except Exception as e: + await http_session.close() + if vod_session: + vod_tracker.end(vod_session.session_id) + raise HTTPException(502, f"Provider unreachable: {e}") + + content_type = resp.headers.get("Content-Type", "application/octet-stream") + forward_headers: dict[str, str] = {"Cache-Control": "no-cache"} + for h in ("Content-Length", "Content-Range", "Accept-Ranges"): + if h in resp.headers: + forward_headers[h] = resp.headers[h] + + log_started = time.time() + + async def generate(): + prev_bytes = 0 + prev_time = time.time() + bytes_sent = 0 + try: + async for chunk in resp.content.iter_chunked(65536): + if vod_session and vod_session.should_stop: + break + bytes_sent += len(chunk) + if vod_session: + now = time.time() + vod_session.bytes_pumped += len(chunk) + vod_session.last_active = now + if now - prev_time >= 2.0: + bps = (vod_session.bytes_pumped - prev_bytes) / (now - prev_time) + vod_session.bps_down = bps + vod_session.bps_up = bps + prev_bytes = vod_session.bytes_pumped + prev_time = now + yield chunk + finally: + resp.release() + await http_session.close() + if vod_session: + vod_session.running = False + vod_tracker.end(vod_session.session_id) + if log_id: + asyncio.create_task(_finish_log(log_id, bytes_sent, log_started)) + + return StreamingResponse( + generate(), + status_code=resp.status, + media_type=content_type, + headers=forward_headers, + ) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +async def _user_info(user: User, password: str, base: str, db: AsyncSession) -> dict: + exp = int(user.expiry_date.timestamp()) if user.expiry_date else 4102444800 # 2100-01-01 + return { + "user_info": { + "username": user.username, + "password": password, + "message": "KiraStream", + "auth": 1, + "status": "Active" if user.is_active else "Disabled", + "exp_date": str(exp), + "is_trial": "0", + "active_cons": "0", + "created_at": str(int(user.created_at.timestamp())), + "max_connections": str(user.max_connections), + "allowed_output_formats": ["ts", "m3u8", "rtmp"], + }, + "server_info": { + "url": base, + "port": "80", + "https_port": "443", + "server_protocol": "http", + "rtmp_port": "1935", + "timezone": "Europe/Madrid", + "timestamp_now": int(time.time()), + "time_now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + }, + } + + +async def _get_custom_cats(db: AsyncSession, ctype: ContentType, user_id: int) -> list[dict]: + allowed_ids = await _get_user_custom_cat_ids(db, user_id) + q = ( + select(CustomCategory) + .where(CustomCategory.type == ctype, CustomCategory.is_visible == True) # noqa: E712 + .order_by(CustomCategory.position, CustomCategory.id) + ) + if allowed_ids is not None: + q = q.where(CustomCategory.id.in_(allowed_ids)) + result = await db.execute(q) + return [ + {"category_id": f"{CC_PREFIX}{c.id}", "category_name": c.name, "parent_id": 0} + for c in result.scalars().all() + ] + + +async def _get_live_categories(user: User, db: AsyncSession) -> list[dict]: + cats = await get_user_categories(db, user, ContentType.live) + result = [{"category_id": str(c.id), "category_name": c.name, "parent_id": 0} for c in cats] + result += await _get_custom_cats(db, ContentType.live, user.id) + return result + + +async def _get_categories(user: User, db: AsyncSession, ctype: ContentType) -> list[dict]: + cats = await get_user_categories(db, user, ctype) + result = [{"category_id": str(c.id), "category_name": c.name, "parent_id": 0} for c in cats] + result += await _get_custom_cats(db, ctype, user.id) + return result + + +async def _get_live_streams(user: User, db: AsyncSession, category_id: str | None, base: str) -> list[dict]: + if category_id and category_id.startswith(CC_PREFIX): + cc_id = int(category_id[len(CC_PREFIX):]) + channels = await _get_custom_category_channels(db, cc_id) + else: + cat_id = int(category_id) if category_id else None + channels = await get_user_channels(db, user, ContentType.live, cat_id) + return [ + { + "num": i + 1, + "name": ch.name, + "stream_type": "live", + "stream_id": int(ch.stream_id_at_provider) if ch.stream_id_at_provider.isdigit() else ch.id, + "stream_icon": ch.tvg_logo or "", + "epg_channel_id": ch.tvg_id or "", + "added": "0", + "category_id": str(ch.category_id) if ch.category_id else "0", + "custom_sid": "", + "tv_archive": 0, + "direct_source": "", + "tv_archive_duration": 0, + } + for i, ch in enumerate(channels) + ] + + +async def _get_channels(user: User, db: AsyncSession, ctype: ContentType, category_id: str | None, base: str) -> list[dict]: + if category_id and category_id.startswith(CC_PREFIX): + cc_id = int(category_id[len(CC_PREFIX):]) + channels = await _get_custom_category_channels(db, cc_id) + else: + cat_id = int(category_id) if category_id else None + channels = await get_user_channels(db, user, ctype, cat_id) + if ctype == ContentType.movie: + return [ + { + "num": i + 1, + "name": ch.name, + "stream_type": "movie", + "stream_id": int(ch.stream_id_at_provider) if ch.stream_id_at_provider.isdigit() else ch.id, + "stream_icon": ch.tvg_logo or "", + "added": "0", + "category_id": str(ch.category_id) if ch.category_id else "0", + "category_ids": [str(ch.category_id) if ch.category_id else "0"], + "container_extension": "mkv", + } + for i, ch in enumerate(channels) + ] + # Series: must return series_id (not stream_id) so apps can call get_series_info + return [ + { + "num": i + 1, + "name": ch.name, + "series_id": int(ch.stream_id_at_provider) if ch.stream_id_at_provider.isdigit() else ch.id, + "stream_id": int(ch.stream_id_at_provider) if ch.stream_id_at_provider.isdigit() else ch.id, + "cover": ch.tvg_logo or "", + "stream_icon": ch.tvg_logo or "", + "plot": "", + "cast": "", + "director": "", + "genre": "", + "release_date": "", + "last_modified": "0", + "rating": "", + "rating_5based": 0, + "backdrop_path": [], + "youtube_trailer": "", + "episode_run_time": "", + "added": "0", + "category_id": str(ch.category_id) if ch.category_id else "0", + "category_ids": [str(ch.category_id) if ch.category_id else "0"], + "container_extension": "mkv", + } + for i, ch in enumerate(channels) + ] + + +async def _get_series_info(series_id: str, user: User, db: AsyncSession) -> dict: + result = await db.execute( + select(Channel) + .options(selectinload(Channel.provider_maps)) + .where(Channel.stream_id_at_provider == series_id, Channel.type == ContentType.series) + ) + ch = result.scalar_one_or_none() + + # Fallback: Jellyfin series have non-numeric stream_id_at_provider so the app + # receives ch.id (DB int) as series_id — look it up by primary key. + if not ch: + try: + ch_id = int(series_id) + fb = await db.execute( + select(Channel) + .options(selectinload(Channel.provider_maps)) + .where(Channel.id == ch_id, Channel.type == ContentType.series) + ) + ch = fb.scalar_one_or_none() + except (ValueError, TypeError): + pass + + if not ch: + return {"info": {}, "episodes": {}} + + # Check if this channel belongs to a Jellyfin config + ji_result = await db.execute( + select(JellyfinItem).where(JellyfinItem.channel_id == ch.id) + ) + ji = ji_result.scalar_one_or_none() + if ji: + cfg_result = await db.execute( + select(JellyfinConfig).where(JellyfinConfig.id == ji.jellyfin_config_id) + ) + cfg = cfg_result.scalar_one_or_none() + if cfg: + return await _get_jellyfin_series_info(ch, ji, cfg) + + if not ch.provider_maps: + return {"info": {}, "episodes": {}} + + pmap = ch.provider_maps[0] + prov_result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == pmap.provider_account_id)) + provider = prov_result.scalar_one_or_none() + if not provider: + return {"info": {}, "episodes": {}} + client = XtreamClient(provider.base_url, provider.username, provider.password) + try: + info = await client.get_series_info(series_id) + # Cache episode_id → provider_id so series_stream can route the playback request + for season_eps in (info.get("episodes") or {}).values(): + if isinstance(season_eps, list): + for ep in season_eps: + ep_id = str(ep.get("id", "")) + if ep_id: + _episode_provider_cache[ep_id] = provider.id + # Ensure cover is populated (some providers return it here, some in the list) + if info.get("info") is not None and not info["info"].get("cover"): + info["info"]["cover"] = ch.tvg_logo or "" + return info + except Exception as e: + logger.warning(f"Series info fetch failed for series_id={series_id}: {e}") + return { + "info": {"name": ch.name, "cover": ch.tvg_logo or "", "plot": "", "cast": "", "director": "", "genre": "", "rating": ""}, + "episodes": {}, + } + + +async def _get_jellyfin_series_info(ch: Channel, ji: JellyfinItem, cfg: JellyfinConfig) -> dict: + """Build get_series_info response from Jellyfin seasons/episodes.""" + from ..core.jellyfin_client import JellyfinClient as _JClient + client = _JClient(cfg.url, cfg.api_key) + episodes_by_season: dict[str, list[dict]] = {} + try: + seasons = await client.get_seasons(ji.jellyfin_item_id) + for season in seasons: + season_id = season.get("Id", "") + season_num = str(season.get("IndexNumber", 1)) + eps = await client.get_episodes(ji.jellyfin_item_id, season_id) + ep_list = [] + for ep_num, ep in enumerate(eps, 1): + ep_jf_id = ep.get("Id", "") + if not ep_jf_id: + continue + # Stable numeric ID derived from the Jellyfin episode ID + fake_id = abs(hash(ep_jf_id)) % (10 ** 9) + stream_url = client.build_stream_url(ep_jf_id) + _jellyfin_episode_cache[str(fake_id)] = stream_url + ep_list.append({ + "id": fake_id, + "episode_num": ep.get("IndexNumber", ep_num), + "title": ep.get("Name", f"Episodio {ep_num}"), + "container_extension": "mkv", + "info": { + "plot": ep.get("Overview", ""), + "duration_secs": 0, + "rating": "", + "name": ep.get("Name", ""), + "air_date": "", + }, + }) + if ep_list: + episodes_by_season[season_num] = ep_list + except Exception as e: + logger.warning(f"Jellyfin seasons fetch failed for {ji.jellyfin_item_id}: {e}") + + return { + "info": { + "name": ch.name, + "cover": ch.tvg_logo or "", + "plot": ji.plot or "", + "cast": "", + "director": "", + "genre": ji.genres or "", + "release_date": str(ji.year) if ji.year else "", + "rating": ji.rating or "", + "backdrop_path": ch.tvg_logo or "", + "youtube_trailer": "", + "episode_run_time": "", + }, + "episodes": episodes_by_season, + } + + +async def _get_vod_info(vod_id: str, user: User, db: AsyncSession) -> dict: + result = await db.execute( + select(Channel) + .options(selectinload(Channel.provider_maps)) + .where(Channel.stream_id_at_provider == vod_id, Channel.type == ContentType.movie) + ) + ch = result.scalar_one_or_none() + # Fallback: Jellyfin movies return ch.id as vod_id (non-numeric stream_id_at_provider) + if not ch: + try: + ch_id = int(vod_id) + fb = await db.execute( + select(Channel) + .options(selectinload(Channel.provider_maps)) + .where(Channel.id == ch_id, Channel.type == ContentType.movie) + ) + ch = fb.scalar_one_or_none() + except (ValueError, TypeError): + pass + if not ch: + return {"info": {}, "movie_data": {}} + # If this is a Jellyfin movie, return its metadata from JellyfinItem + ji_result = await db.execute(select(JellyfinItem).where(JellyfinItem.channel_id == ch.id)) + ji = ji_result.scalar_one_or_none() + if ji: + return { + "info": { + "name": ch.name, + "cover": ch.tvg_logo or "", + "plot": ji.plot or "", + "cast": "", + "director": "", + "genre": ji.genres or "", + "release_date": str(ji.year) if ji.year else "", + "rating": ji.rating or "", + "duration_secs": 0, + "backdrop_path": ch.tvg_logo or "", + "youtube_trailer": "", + "tmdb_id": "", + }, + "movie_data": { + "stream_id": str(ch.id), + "name": ch.name, + "added": "0", + "category_id": str(ch.category_id) if ch.category_id else "0", + "container_extension": "mkv", + "custom_sid": "", + "direct_source": "", + }, + } + if ch.provider_maps: + pmap = ch.provider_maps[0] + prov_result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == pmap.provider_account_id)) + provider = prov_result.scalar_one_or_none() + if provider: + client = XtreamClient(provider.base_url, provider.username, provider.password) + try: + return await client.get_vod_info(vod_id) + except Exception as e: + logger.warning(f"VOD info fetch failed for vod_id={vod_id}: {e}") + return { + "info": {"name": ch.name, "cover": ch.tvg_logo or "", "plot": "", "cast": "", "director": "", "genre": "", "release_date": "", "rating": ""}, + "movie_data": {"stream_id": ch.stream_id_at_provider, "container_extension": "mkv"}, + } diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..d27f70f --- /dev/null +++ b/backend/config.py @@ -0,0 +1,34 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + database_url: str = "postgresql+asyncpg://kirastream:kirastream_secret_2024@localhost/kirastream" + redis_url: str = "redis://localhost:6379/0" + secret_key: str = "kirastream-super-secret-change-in-production-2024" + algorithm: str = "HS256" + access_token_expire_minutes: int = 60 * 24 * 7 # 7 days + + admin_username: str = "admin" + admin_password: str = "kiraadmin" + + stream_chunk_size: int = 65536 # 64 KB chunks + stream_queue_maxsize: int = 512 + stream_connect_timeout: int = 10 + stream_read_timeout: int = 30 + + epg_refresh_hours: int = 12 + m3u_cache_minutes: int = 30 + + log_level: str = "INFO" + + class Config: + env_file = "/opt/kirastream/.env" + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/catalog.py b/backend/core/catalog.py new file mode 100644 index 0000000..bc6aad3 --- /dev/null +++ b/backend/core/catalog.py @@ -0,0 +1,78 @@ +"""Resolve channel catalog for a given user based on their assigned categories.""" +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from ..models.user import User, UserCatalogEntry, UserProviderPreference +from ..models.channel import Channel, Category, ChannelProviderMap, ContentType +from ..models.provider import ProviderAccount + + +async def get_user_categories(db: AsyncSession, user: User, content_type: ContentType | None = None) -> list[Category]: + q = ( + select(Category) + .join(UserCatalogEntry, UserCatalogEntry.category_id == Category.id) + .where(UserCatalogEntry.user_id == user.id) + ) + if content_type: + q = q.where(Category.type == content_type) + result = await db.execute(q) + return list(result.scalars().all()) + + +async def get_user_channels( + db: AsyncSession, + user: User, + content_type: ContentType | None = None, + category_id: int | None = None, +) -> list[Channel]: + category_ids_q = ( + select(UserCatalogEntry.category_id) + .where(UserCatalogEntry.user_id == user.id) + ) + + q = ( + select(Channel) + .options(selectinload(Channel.provider_maps), selectinload(Channel.category)) + .where(Channel.category_id.in_(category_ids_q)) + .where(Channel.is_active == True) # noqa: E712 + ) + if content_type: + q = q.where(Channel.type == content_type) + if category_id: + q = q.where(Channel.category_id == category_id) + + result = await db.execute(q) + return list(result.scalars().all()) + + +async def get_channel_provider_maps(db: AsyncSession, channel_id: int) -> list[dict]: + q = ( + select(ChannelProviderMap, ProviderAccount.name.label("provider_name")) + .join(ProviderAccount, ProviderAccount.id == ChannelProviderMap.provider_account_id) + .where(ChannelProviderMap.channel_id == channel_id) + ) + result = await db.execute(q) + rows = result.all() + return [ + { + "provider_account_id": row.ChannelProviderMap.provider_account_id, + "stream_url": row.ChannelProviderMap.stream_url, + "provider_name": row.provider_name or f"#{row.ChannelProviderMap.provider_account_id}", + } + for row in rows + ] + + +async def can_user_access_channel(db: AsyncSession, user: User, channel_id: int) -> bool: + channels = await get_user_channels(db, user) + return any(ch.id == channel_id for ch in channels) + + +async def get_user_preferred_provider_ids(db: AsyncSession, user: User) -> list[int]: + result = await db.execute( + select(UserProviderPreference.provider_account_id) + .where(UserProviderPreference.user_id == user.id) + .order_by(UserProviderPreference.priority) + ) + return list(result.scalars().all()) diff --git a/backend/core/epg_manager.py b/backend/core/epg_manager.py new file mode 100644 index 0000000..fa68e0d --- /dev/null +++ b/backend/core/epg_manager.py @@ -0,0 +1,99 @@ +"""EPG manager: fetch XMLTV sources, merge by tvg-id, serve filtered by user catalog.""" +import asyncio +import gzip +import logging +import time +from datetime import datetime, timezone +from io import BytesIO +from lxml import etree +import aiohttp +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from ..models.epg import EpgSource +from ..config import settings + +logger = logging.getLogger(__name__) + +_merged_epg: bytes = b"" +_last_update: float = 0.0 +_update_lock = asyncio.Lock() + + +async def refresh_epg(db: AsyncSession) -> None: + global _merged_epg, _last_update + async with _update_lock: + result = await db.execute(select(EpgSource).where(EpgSource.is_active == True)) # noqa: E712 + sources = result.scalars().all() + if not sources: + logger.info("No active EPG sources configured") + return + + root = etree.Element("tv") + timeout = aiohttp.ClientTimeout(total=60) + + async with aiohttp.ClientSession(timeout=timeout) as session: + for source in sources: + try: + data = await _fetch_xmltv(session, source.url) + tree = etree.fromstring(data) + for elem in tree: + root.append(elem) + logger.info(f"EPG loaded from {source.name}: {len(tree)} entries") + except Exception as e: + logger.error(f"EPG fetch failed for {source.name}: {e}") + + _merged_epg = etree.tostring(root, xml_declaration=True, encoding="UTF-8") + _last_update = time.time() + + from sqlalchemy import update + await db.execute( + update(EpgSource).where(EpgSource.is_active == True).values(last_fetched_at=datetime.now(timezone.utc)) # noqa: E712 + ) + await db.commit() + logger.info(f"EPG merge complete, {len(_merged_epg)} bytes") + + +async def _fetch_xmltv(session: aiohttp.ClientSession, url: str) -> bytes: + async with session.get(url, ssl=False) as resp: + resp.raise_for_status() + data = await resp.read() + if data[:2] == b"\x1f\x8b": + data = gzip.decompress(data) + return data + + +def get_merged_epg() -> bytes: + return _merged_epg + + +def filter_epg_for_channels(tvg_ids: set[str]) -> bytes: + """Return EPG XML filtered to only contain channels/programs for given tvg-ids.""" + if not _merged_epg: + return b'' + try: + tree = etree.fromstring(_merged_epg) + root = etree.Element("tv") + for elem in tree: + tag = elem.tag + if tag == "channel": + if elem.get("id") in tvg_ids: + root.append(elem) + elif tag == "programme": + if elem.get("channel") in tvg_ids: + root.append(elem) + return etree.tostring(root, xml_declaration=True, encoding="UTF-8") + except Exception as e: + logger.error(f"EPG filter error: {e}") + return _merged_epg + + +async def epg_background_task(get_db_func) -> None: + """Periodic EPG refresh task — runs every N hours.""" + while True: + try: + async for db in get_db_func(): + await refresh_epg(db) + except Exception as e: + logger.error(f"EPG background refresh error: {e}") + await asyncio.sleep(settings.epg_refresh_hours * 3600) diff --git a/backend/core/health_checker.py b/backend/core/health_checker.py new file mode 100644 index 0000000..66ec71b --- /dev/null +++ b/backend/core/health_checker.py @@ -0,0 +1,115 @@ +"""Background health checker for provider URLs.""" +import asyncio +import logging +import time +from datetime import datetime, timezone + +import aiohttp +from sqlalchemy import select + +from ..database import AsyncSessionLocal +from ..models.provider import ProviderAccount, ProviderUrl + +logger = logging.getLogger(__name__) + +CHECK_INTERVAL = 300 # 5 minutes + + +async def _check_url(base_url: str, username: str, password: str) -> tuple[str, int | None]: + url = f"{base_url.rstrip('/')}/player_api.php" + params = {"username": username, "password": password, "action": "user_info"} + start = time.time() + try: + timeout = aiohttp.ClientTimeout(total=8) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url, params=params, ssl=False) as resp: + elapsed_ms = int((time.time() - start) * 1000) + if resp.status == 200: + try: + data = await resp.json(content_type=None) + if "user_info" in data: + return "ok", elapsed_ms + except Exception: + pass + return "error", elapsed_ms + except asyncio.TimeoutError: + return "timeout", None + except Exception: + return "error", None + + +async def check_provider_urls(provider_account_id: int) -> list[dict]: + """Check all URLs for a specific provider. Returns list of result dicts.""" + async with AsyncSessionLocal() as db: + result = await db.execute( + select(ProviderUrl.id, ProviderUrl.url, ProviderAccount.username, ProviderAccount.password) + .join(ProviderAccount) + .where( + ProviderUrl.provider_account_id == provider_account_id, + ProviderUrl.is_active == True, # noqa: E712 + ) + ) + rows = result.all() + + if not rows: + return [] + + tasks = [_check_url(url, username, password) for _, url, username, password in rows] + results = await asyncio.gather(*tasks, return_exceptions=True) + + now = datetime.now(timezone.utc) + output = [] + async with AsyncSessionLocal() as db: + for (url_id, url, _, _), check_result in zip(rows, results): + if isinstance(check_result, Exception): + status, response_ms = "error", None + else: + status, response_ms = check_result + pu = await db.get(ProviderUrl, url_id) + if pu: + pu.status = status + pu.response_ms = response_ms + pu.last_checked_at = now + output.append({"id": url_id, "url": url, "status": status, "response_ms": response_ms}) + await db.commit() + + return output + + +async def health_check_loop() -> None: + """Runs forever, checking all active provider URLs every CHECK_INTERVAL seconds.""" + logger.info("Provider URL health checker started") + while True: + await asyncio.sleep(CHECK_INTERVAL) + try: + async with AsyncSessionLocal() as db: + result = await db.execute( + select(ProviderUrl.id, ProviderUrl.url, ProviderAccount.username, ProviderAccount.password) + .join(ProviderAccount) + .where(ProviderUrl.is_active == True) # noqa: E712 + ) + rows = result.all() + + if not rows: + continue + + tasks = [_check_url(url, username, password) for _, url, username, password in rows] + check_results = await asyncio.gather(*tasks, return_exceptions=True) + + now = datetime.now(timezone.utc) + async with AsyncSessionLocal() as db: + for (url_id, _, _, _), check_result in zip(rows, check_results): + if isinstance(check_result, Exception): + status, response_ms = "error", None + else: + status, response_ms = check_result + pu = await db.get(ProviderUrl, url_id) + if pu: + pu.status = status + pu.response_ms = response_ms + pu.last_checked_at = now + await db.commit() + + logger.info(f"Health check done: {len(rows)} URLs checked") + except Exception as e: + logger.error(f"Health check loop error: {e}") diff --git a/backend/core/jellyfin_client.py b/backend/core/jellyfin_client.py new file mode 100644 index 0000000..59eb278 --- /dev/null +++ b/backend/core/jellyfin_client.py @@ -0,0 +1,120 @@ +"""Async Jellyfin API client — discovery, libraries, items, streaming.""" +import logging + +import aiohttp + +logger = logging.getLogger(__name__) + +_SHORT = aiohttp.ClientTimeout(total=10) +_MEDIUM = aiohttp.ClientTimeout(total=30) +_LONG = aiohttp.ClientTimeout(total=60) + + +class JellyfinClient: + def __init__(self, url: str, api_key: str): + self.url = url.rstrip("/") + self.api_key = api_key + + @property + def _headers(self) -> dict: + return {"X-Emby-Token": self.api_key, "Accept": "application/json"} + + # ------------------------------------------------------------------ + # Discovery + # ------------------------------------------------------------------ + + async def test_connection(self) -> dict: + async with aiohttp.ClientSession() as s: + async with s.get( + f"{self.url}/System/Info/Public", timeout=_SHORT + ) as r: + r.raise_for_status() + return await r.json() + + async def get_libraries(self) -> list[dict]: + async with aiohttp.ClientSession() as s: + async with s.get( + f"{self.url}/Library/VirtualFolders", + headers=self._headers, + timeout=_SHORT, + ) as r: + r.raise_for_status() + data = await r.json() + return data if isinstance(data, list) else [] + + # ------------------------------------------------------------------ + # Items (movies / series) + # ------------------------------------------------------------------ + + async def get_items(self, library_id: str, item_type: str) -> list[dict]: + """Fetch all items of a given type from one library (paginated).""" + all_items: list[dict] = [] + start = 0 + limit = 200 + while True: + params = { + "ParentId": library_id, + "IncludeItemTypes": item_type, + "Recursive": "true", + "Fields": "Overview,Genres,OfficialRating,ProductionYear", + "Limit": str(limit), + "StartIndex": str(start), + } + async with aiohttp.ClientSession() as s: + async with s.get( + f"{self.url}/Items", + params=params, + headers=self._headers, + timeout=_LONG, + ) as r: + r.raise_for_status() + data = await r.json() + batch = data.get("Items", []) + all_items.extend(batch) + total = data.get("TotalRecordCount", 0) + if len(all_items) >= total or not batch: + break + start += len(batch) + return all_items + + # ------------------------------------------------------------------ + # Series structure (seasons + episodes) + # ------------------------------------------------------------------ + + async def get_seasons(self, series_id: str) -> list[dict]: + async with aiohttp.ClientSession() as s: + async with s.get( + f"{self.url}/Shows/{series_id}/Seasons", + params={"Fields": "Overview"}, + headers=self._headers, + timeout=_MEDIUM, + ) as r: + r.raise_for_status() + return (await r.json()).get("Items", []) + + async def get_episodes(self, series_id: str, season_id: str) -> list[dict]: + async with aiohttp.ClientSession() as s: + async with s.get( + f"{self.url}/Shows/{series_id}/Episodes", + params={"SeasonId": season_id, "Fields": "Overview"}, + headers=self._headers, + timeout=_MEDIUM, + ) as r: + r.raise_for_status() + return (await r.json()).get("Items", []) + + # ------------------------------------------------------------------ + # URL builders + # ------------------------------------------------------------------ + + def build_stream_url(self, item_id: str) -> str: + return ( + f"{self.url}/Videos/{item_id}/stream" + f"?static=true&api_key={self.api_key}&Container=mkv" + ) + + def build_image_url(self, item_id: str, max_width: int = 400) -> str: + return ( + f"{self.url}/Items/{item_id}/Images/Primary" + f"?api_key={self.api_key}&MaxWidth={max_width}" + ) diff --git a/backend/core/pool.py b/backend/core/pool.py new file mode 100644 index 0000000..54909f1 --- /dev/null +++ b/backend/core/pool.py @@ -0,0 +1,314 @@ +"""ProviderPool: manages provider account slots and live stream allocation.""" +import asyncio +import json +import logging +import time +from typing import AsyncIterator + +import redis.asyncio as aioredis + +from ..config import settings +from .restream import BroadcastGroup, ClientHandle + +logger = logging.getLogger(__name__) + +ACTIVE_STREAM_KEY = "ks:stream:{channel_id}" +PROVIDER_SLOT_KEY = "ks:slot:{account_id}" +USER_CONN_KEY = "ks:user_conns:{user_id}" +MONITORING_KEY = "ks:monitoring" + + +class NoSlotsAvailableError(Exception): + pass + + +class MaxConnectionsExceededError(Exception): + pass + + +class ProviderPool: + """ + Singleton managing all active BroadcastGroups and provider slot allocation. + Uses Redis for cross-process state and in-memory dict for the asyncio objects. + """ + + def __init__(self): + self._groups: dict[int, BroadcastGroup] = {} + self._lock = asyncio.Lock() + self._redis: aioredis.Redis | None = None + + async def init(self) -> None: + self._redis = aioredis.from_url(settings.redis_url, decode_responses=True) + await self._redis.ping() + # Clear any stale keys left from a previous process crash or restart + stale = await self._redis.keys("ks:*") + if stale: + await self._redis.delete(*stale) + logger.info(f"ProviderPool cleared {len(stale)} stale Redis keys on startup") + logger.info("ProviderPool initialized, Redis connected") + + async def close(self) -> None: + if self._redis: + await self._redis.aclose() + + # ------------------------------------------------------------------------- + # Public interface + # ------------------------------------------------------------------------- + + async def acquire( + self, + channel_id: int, + stream_url_resolver, # async callable(provider_account_id) -> str + provider_maps: list[dict], # [{"provider_account_id": X, "stream_url": Y, "provider_name": Z}, ...] + user_id: str, + user_max_conns: int, + is_priority: bool = False, + preferred_provider_ids: list[int] | None = None, + username: str = "", + channel_name: str = "", + ) -> ClientHandle: + await self._check_user_connections(user_id, user_max_conns) + + async with self._lock: + # Case 1: stream already active → attach to existing group + if channel_id in self._groups: + group = self._groups[channel_id] + handle = await group.add_client(user_id, username=username, is_priority=is_priority) + await self._redis.hincrby(ACTIVE_STREAM_KEY.format(channel_id=channel_id), "clients", 1) + await self._redis.sadd(USER_CONN_KEY.format(user_id=user_id), handle.client_id) + await self._publish_monitoring() + logger.info(f"User {user_id} attached to existing stream ch={channel_id}") + return handle + + # Case 2: find a free slot (preferred providers sorted first) + sorted_maps = self._sort_by_preference(provider_maps, preferred_provider_ids) + account_id, stream_url = await self._find_free_slot(sorted_maps) + + if account_id is None: + if not is_priority: + raise NoSlotsAvailableError(f"No free provider slots for channel {channel_id}") + + # Case 3: priority eviction — bump a non-priority stream + account_id, stream_url, victim_group = await self._find_eviction_candidate(sorted_maps) + if account_id is None: + raise NoSlotsAvailableError(f"No evictable non-priority streams for channel {channel_id}") + + # Clean Redis for the victim stream inside the lock + victim_channel_id = victim_group.channel_id + victim_account_id = victim_group.provider_account_id + self._groups.pop(victim_channel_id, None) + await self._redis.delete(ACTIVE_STREAM_KEY.format(channel_id=victim_channel_id)) + await self._redis.delete(PROVIDER_SLOT_KEY.format(account_id=victim_account_id)) + # Stop victim group outside lock (disconnect all its clients via SENTINEL) + asyncio.create_task(victim_group.stop()) + logger.info( + f"Priority eviction: stopped ch={victim_channel_id} " + f"(provider_account={victim_account_id}) for priority user {user_id}" + ) + + # Reserve slot + await self._redis.set(PROVIDER_SLOT_KEY.format(account_id=account_id), channel_id) + + provider_name = next( + (pm.get("provider_name", "") for pm in provider_maps if pm["provider_account_id"] == account_id), "" + ) + stream_urls = await self._build_stream_urls(account_id, stream_url) + group = BroadcastGroup( + channel_id=channel_id, + stream_urls=stream_urls, + provider_account_id=account_id, + channel_name=channel_name, + provider_name=provider_name, + ) + self._groups[channel_id] = group + handle = await group.add_client(user_id, username=username, is_priority=is_priority) + + await self._redis.hset( + ACTIVE_STREAM_KEY.format(channel_id=channel_id), + mapping={ + "provider_account_id": account_id, + "stream_url": stream_url, + "clients": 1, + "started_at": time.time(), + }, + ) + await self._redis.sadd(USER_CONN_KEY.format(user_id=user_id), handle.client_id) + await group.start() + await self._publish_monitoring() + logger.info(f"New stream started ch={channel_id} provider_account={account_id}") + return handle + + async def release(self, channel_id: int, client_id: str, user_id: str) -> None: + group_to_stop: BroadcastGroup | None = None + stopped_account_id: int | None = None + + async with self._lock: + group = self._groups.get(channel_id) + if not group: + return + + remaining = await group.remove_client(client_id) + await self._redis.srem(USER_CONN_KEY.format(user_id=user_id), client_id) + + if remaining == 0: + # Remove from registry and clean Redis *while holding the lock*, + # but defer group.stop() (which awaits task cancellation) until + # after the lock is released to avoid blocking the pool. + self._groups.pop(channel_id, None) + group_to_stop = group + stopped_account_id = group.provider_account_id + await self._redis.delete(ACTIVE_STREAM_KEY.format(channel_id=channel_id)) + await self._redis.delete(PROVIDER_SLOT_KEY.format(account_id=stopped_account_id)) + else: + await self._redis.hset( + ACTIVE_STREAM_KEY.format(channel_id=channel_id), "clients", remaining + ) + + await self._publish_monitoring() + + if group_to_stop is not None: + await group_to_stop.stop() + logger.info(f"Stream stopped ch={channel_id}, slot freed: provider_account={stopped_account_id}") + + async def kill_client(self, client_id: str) -> bool: + """Admin: force-disconnect a specific client by sending SENTINEL to their queue.""" + async with self._lock: + for group in self._groups.values(): + if client_id in group._clients: + return await group.force_disconnect_client(client_id) + return False + + def get_active_streams(self) -> list[dict]: + return [g.stats() for g in self._groups.values()] + + async def get_user_connection_count(self, user_id: str) -> int: + return await self._redis.scard(USER_CONN_KEY.format(user_id=user_id)) + + # ------------------------------------------------------------------------- + # Internal helpers + # ------------------------------------------------------------------------- + + async def _check_user_connections(self, user_id: str, max_conns: int) -> None: + # Cross-check Redis set against in-memory groups to auto-heal stale entries + redis_conns = await self._redis.smembers(USER_CONN_KEY.format(user_id=user_id)) + if redis_conns: + active = {cid for g in self._groups.values() for cid in g.client_ids} + stale = redis_conns - active + if stale: + await self._redis.srem(USER_CONN_KEY.format(user_id=user_id), *stale) + logger.warning(f"Auto-cleaned {len(stale)} stale conn(s) for user {user_id}") + redis_conns -= stale + count = len(redis_conns) + if count >= max_conns: + # Determine if this is a zapping scenario (same device switching channels quickly) + # or a genuine multi-device connection attempt. + # Zapping: the existing connection is very recent (< 10 s) — the TV app opens the + # new stream before the HTTP response of the old one closes. In that case we evict + # silently so the channel switch is seamless. + # Multi-device: the existing connection is older — keep it alive and raise so the + # caller can show a "blocked" stream on the new device instead. + now = time.time() + groups_snapshot = list(self._groups.values()) + oldest_age = 0.0 + for group in groups_snapshot: + for cid, handle in group._clients.items(): + if cid in redis_conns: + oldest_age = max(oldest_age, now - handle.connected_at) + + if oldest_age < 10.0: + # Zapping — evict silently + logger.info(f"User {user_id} zapping ({oldest_age:.1f}s old conn) — evicting old stream") + for client_id in list(redis_conns): + for group in groups_snapshot: + if client_id in group._clients: + await group.force_disconnect_client(client_id) + break + await self._redis.srem(USER_CONN_KEY.format(user_id=user_id), client_id) + else: + # Multi-device — reject so caller can show blocked image + raise MaxConnectionsExceededError( + f"User {user_id} already has {count}/{max_conns} connections" + ) + + def _sort_by_preference( + self, provider_maps: list[dict], preferred_ids: list[int] | None + ) -> list[dict]: + if not preferred_ids: + return provider_maps + pref_set = set(preferred_ids) + preferred = [pm for pm in provider_maps if pm["provider_account_id"] in pref_set] + rest = [pm for pm in provider_maps if pm["provider_account_id"] not in pref_set] + return preferred + rest + + async def _build_stream_urls(self, account_id: int, primary_stream_url: str) -> list[str]: + """Build list of stream URLs for failover: one per active ProviderUrl, substituting base URL.""" + from urllib.parse import urlparse, urlunparse + from ..models.provider import ProviderUrl + from ..database import AsyncSessionLocal + from sqlalchemy import select + + parsed = urlparse(primary_stream_url) + + async with AsyncSessionLocal() as db: + result = await db.execute( + select(ProviderUrl) + .where( + ProviderUrl.provider_account_id == account_id, + ProviderUrl.is_active == True, # noqa: E712 + ) + .order_by(ProviderUrl.priority) + ) + provider_urls = result.scalars().all() + + if not provider_urls: + return [primary_stream_url] + + urls = [] + for pu in provider_urls: + alt = urlparse(pu.url.rstrip('/')) + reconstructed = urlunparse(( + alt.scheme or parsed.scheme, + alt.netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + )) + urls.append(reconstructed) + return urls + + async def _find_free_slot(self, provider_maps: list[dict]) -> tuple[int | None, str | None]: + for pm in provider_maps: + account_id = pm["provider_account_id"] + slot_key = PROVIDER_SLOT_KEY.format(account_id=account_id) + busy = await self._redis.exists(slot_key) + if not busy: + return account_id, pm["stream_url"] + return None, None + + async def _find_eviction_candidate( + self, provider_maps: list[dict] + ) -> tuple[int | None, str | None, BroadcastGroup | None]: + """Find a stream with only non-priority clients that uses a slot we need.""" + available = {pm["provider_account_id"]: pm["stream_url"] for pm in provider_maps} + + candidate: BroadcastGroup | None = None + for group in self._groups.values(): + if group.provider_account_id in available and group.all_non_priority: + if candidate is None or group.client_count < candidate.client_count: + candidate = group + + if candidate is None: + return None, None, None + + return candidate.provider_account_id, available[candidate.provider_account_id], candidate + + async def _publish_monitoring(self) -> None: + try: + data = json.dumps([g.stats() for g in self._groups.values()]) + await self._redis.publish(MONITORING_KEY, data) + except Exception: + pass + + +pool = ProviderPool() diff --git a/backend/core/probe.py b/backend/core/probe.py new file mode 100644 index 0000000..6976e76 --- /dev/null +++ b/backend/core/probe.py @@ -0,0 +1,112 @@ +"""FFprobe-based stream metadata — resolution, FPS, codecs, bitrate.""" +import asyncio +import json +import logging +import time + +logger = logging.getLogger(__name__) + +# Simple in-process cache: url → (info_dict, fetched_at) +_cache: dict[str, tuple[dict, float]] = {} +_CACHE_TTL = 3600 # 1 hour — re-probe if URL expires or stream restarts + +# Locks per URL to avoid parallel probes for the same stream +_locks: dict[str, asyncio.Lock] = {} + + +async def probe_stream(url: str) -> dict: + """ + Run ffprobe against *url* and return a dict with video/audio metadata. + Returns {} on failure (non-fatal — the stream still works). + Results are cached for CACHE_TTL seconds. + """ + now = time.time() + cached = _cache.get(url) + if cached and now - cached[1] < _CACHE_TTL: + return cached[0] + + if url not in _locks: + _locks[url] = asyncio.Lock() + + async with _locks[url]: + # Re-check after acquiring lock (another task may have already probed) + cached = _cache.get(url) + if cached and now - cached[1] < _CACHE_TTL: + return cached[0] + + info = await _run_ffprobe(url) + _cache[url] = (info, time.time()) + return info + + +async def _run_ffprobe(url: str) -> dict: + try: + proc = await asyncio.create_subprocess_exec( + "ffprobe", + "-v", "quiet", + "-print_format", "json", + "-show_streams", + "-show_format", + "-analyzeduration", "3000000", # 3 s — fast enough for live streams + "-probesize", "1000000", # 1 MB + url, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + try: + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=20) + except asyncio.TimeoutError: + try: + proc.kill() + except ProcessLookupError: + pass + logger.debug(f"ffprobe timed out for {url}") + return {} + + data = json.loads(stdout) + except Exception as e: + logger.debug(f"ffprobe failed for {url}: {e}") + return {} + + info: dict = {} + + for stream in data.get("streams", []): + ctype = stream.get("codec_type", "") + if ctype == "video" and "video_codec" not in info: + info["video_codec"] = stream.get("codec_name", "") + info["video_profile"] = stream.get("profile", "") + info["width"] = stream.get("width", 0) + info["height"] = stream.get("height", 0) + # FPS: prefer avg_frame_rate, fallback r_frame_rate + for fps_field in ("avg_frame_rate", "r_frame_rate"): + fps_str = stream.get(fps_field, "") + if fps_str and "/" in fps_str: + try: + num, den = fps_str.split("/") + fps = round(int(num) / int(den), 3) if int(den) else 0 + if 1 < fps < 200: # sanity check + info["fps"] = round(fps, 2) + break + except (ValueError, ZeroDivisionError): + pass + + elif ctype == "audio" and "audio_codec" not in info: + info["audio_codec"] = stream.get("codec_name", "") + info["audio_channels"] = stream.get("channels", 0) + info["audio_sample_rate"] = int(stream.get("sample_rate", 0) or 0) + + fmt = data.get("format", {}) + try: + br = int(fmt.get("bit_rate", 0) or 0) + if br > 0: + info["bitrate_bps"] = br + except (ValueError, TypeError): + pass + + logger.debug(f"Probed {url}: {info}") + return info + + +def invalidate(url: str) -> None: + """Evict a URL from the cache (call when stream URL changes).""" + _cache.pop(url, None) diff --git a/backend/core/restream.py b/backend/core/restream.py new file mode 100644 index 0000000..d2ca9d3 --- /dev/null +++ b/backend/core/restream.py @@ -0,0 +1,610 @@ +"""BroadcastGroup: fan-out a single provider stream to N simultaneous clients. + +Recovery model +-------------- +The pump loop runs indefinitely while self._running is True. Each iteration +spawns an ffmpeg process in copy (remux) mode. ffmpeg owns the upstream +HTTP connection. When the provider closes the session (typically every +~2 minutes) ffmpeg exits and the outer loop spawns a fresh process — the +provider delivers from the current live edge so timestamps are always +forward-moving and the player never sees a backward seek (rewind). + +If ffmpeg itself exits (unrecoverable error or stall beyond its own timeout) +the outer loop drains all client queues, backs off briefly, and spawns a +fresh ffmpeg process — again without disconnecting clients. + +SENTINEL is only pushed when stop() is called or the pump truly gives up. + +Stall detection +--------------- +ffmpeg's -timeout option (socket-level read timeout) handles provider stalls. +A safety-net asyncio.wait_for wraps each stdout read in case ffmpeg hangs +without producing output or exiting. + +A/V sync +-------- +ffmpeg re-muxes the incoming MPEG-TS into a fresh output stream. On every +reconnect the output PTS/DTS sequence is continuous regardless of provider- +side timestamp resets, eliminating audio desync entirely. No video or audio +is re-encoded — quality is bit-for-bit identical to the source. +""" +import asyncio +import logging +import os +import shutil +import time +import uuid +from dataclasses import dataclass, field +from typing import AsyncIterator + +from ..config import settings + +logger = logging.getLogger(__name__) + +CHUNK_SIZE = settings.stream_chunk_size +QUEUE_MAX = settings.stream_queue_maxsize +STALL_TIMEOUT = 10.0 # seconds — passed to ffmpeg -timeout (in µs below) +MAX_RETRIES = 999 # effectively infinite; pool stops the group when 0 clients +_BACKOFF_CAP = 5.0 # max seconds between outer retries + +_SENTINEL = object() # signals end-of-stream to client queues + +_FFMPEG = shutil.which("ffmpeg") or "ffmpeg" + +# ffmpeg stderr patterns that indicate input-side A/V issues (demuxer warnings). +# These don't affect output quality with -use_wallclock_as_timestamps but are +# counted as a health metric to surface in the dashboard. +_AV_WARN_PATTERNS = ( + "timestamp discontinuity", + "Packet corrupt", + "PES packet size mismatch", + "DTS, out of order", +) + +# Health check: data-age thresholds +_HEALTH_WARN_SECS = 12.0 # > 12 s without data → warning (normal reconnects are 1-4 s) +_HEALTH_ERROR_SECS = 25.0 # > 25 s → error, trigger correction + + +def _url_netloc(url: str) -> str: + from urllib.parse import urlparse + try: + return urlparse(url).netloc + except Exception: + return url + + +@dataclass +class ClientHandle: + client_id: str + queue: asyncio.Queue = field(default_factory=lambda: asyncio.Queue(maxsize=QUEUE_MAX)) + connected_at: float = field(default_factory=time.time) + is_priority: bool = False + username: str = "" + _overflow_count: int = 0 + _overflow_since: float | None = None + + async def read(self) -> AsyncIterator[bytes]: + while True: + chunk = await self.queue.get() + if chunk is _SENTINEL: + break + yield chunk + + +class BroadcastGroup: + """Streams one provider URL and distributes chunks to all registered clients.""" + + def __init__( + self, + channel_id: int, + stream_urls: list[str], + provider_account_id: int, + channel_name: str = "", + provider_name: str = "", + # kept for backward compat in callers that still pass stream_url as kwarg + stream_url: str | None = None, + ): + self.channel_id = channel_id + self.channel_name = channel_name + # Support old callers that pass a single stream_url + if stream_urls: + self._stream_urls = stream_urls + elif stream_url: + self._stream_urls = [stream_url] + else: + self._stream_urls = [] + self.stream_url = self._stream_urls[0] if self._stream_urls else "" + self._active_url: str = self.stream_url + self.provider_account_id = provider_account_id + self.provider_name = provider_name + self.started_at = time.time() + + self._clients: dict[str, ClientHandle] = {} + self._lock = asyncio.Lock() + self._task: asyncio.Task | None = None + self._running = False + + # Throughput tracking + self._bytes_pumped: int = 0 + self._bytes_prev: int = 0 + self._bps_time: float = time.time() + self._bps_down: float = 0.0 + + # Health / metadata + self.stream_info: dict = {} + self._reconnect_count: int = 0 + self._status: str = "connecting" + self._last_data_at: float = time.time() + + # ffmpeg process tracking + self._ffmpeg_pid: int | None = None + self._ffmpeg_start_time: float | None = None # wall time when ffmpeg started + self._ffmpeg_cpu: float = 0.0 + self._cpu_task: asyncio.Task | None = None + + # A/V health tracking + self._av_health: str = "ok" # ok / warning / error + self._av_desync_count: int = 0 # cumulative stderr A/V-warning events + self._corrections: int = 0 # how many times health monitor killed stuck ffmpeg + self._health_task: asyncio.Task | None = None + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + @property + def client_count(self) -> int: + return len(self._clients) + + @property + def client_ids(self) -> list[str]: + return list(self._clients.keys()) + + @property + def all_non_priority(self) -> bool: + return all(not h.is_priority for h in self._clients.values()) + + async def start(self) -> None: + self._running = True + self._task = asyncio.create_task( + self._pump_with_retry(), name=f"pump-ch{self.channel_id}" + ) + self._cpu_task = asyncio.create_task( + self._monitor_cpu(), name=f"cpu-ch{self.channel_id}" + ) + self._health_task = asyncio.create_task( + self._health_check_loop(), name=f"health-ch{self.channel_id}" + ) + asyncio.create_task(self._probe_info(), name=f"probe-ch{self.channel_id}") + + async def add_client(self, user_id: str, username: str = "", is_priority: bool = False) -> ClientHandle: + client_id = f"{user_id}-{uuid.uuid4().hex[:8]}" + handle = ClientHandle(client_id=client_id, is_priority=is_priority, username=username) + async with self._lock: + self._clients[client_id] = handle + logger.info( + f"Client {client_id} ({username}) joined channel {self.channel_id} " + f"(total={len(self._clients)})" + ) + return handle + + async def force_disconnect_client(self, client_id: str) -> bool: + async with self._lock: + handle = self._clients.get(client_id) + if handle is None: + return False + _drain_queue(handle.queue) + try: + handle.queue.put_nowait(_SENTINEL) + except asyncio.QueueFull: + pass + return True + + async def remove_client(self, client_id: str) -> int: + async with self._lock: + self._clients.pop(client_id, None) + remaining = len(self._clients) + logger.info(f"Client {client_id} left channel {self.channel_id} (remaining={remaining})") + return remaining + + async def stop(self) -> None: + self._running = False + self._status = "stopped" + for task in (self._cpu_task, self._health_task): + if task and not task.done(): + task.cancel() + self._cpu_task = self._health_task = None + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + async with self._lock: + for handle in self._clients.values(): + try: + handle.queue.put_nowait(_SENTINEL) + except asyncio.QueueFull: + pass + + def stats(self) -> dict: + n = self.client_count or 1 + return { + "stream_type": "live", + "channel_id": self.channel_id, + "channel_name": self.channel_name or f"Canal #{self.channel_id}", + "provider_account_id": self.provider_account_id, + "provider_name": self.provider_name, + "client_count": self.client_count, + "clients": [ + { + "client_id": h.client_id, + "username": h.username or h.client_id.rsplit("-", 1)[0], + } + for h in self._clients.values() + ], + "started_at": self.started_at, + "bytes_pumped": self._bytes_pumped, + "bps_down": self._bps_down, + "bps_up": self._bps_down * n, + "running": self._running, + "status": self._status, + "reconnect_count": self._reconnect_count, + "last_data_ago": round(time.time() - self._last_data_at, 1), + "stream_info": self.stream_info, + # ffmpeg process info + "ffmpeg_pid": self._ffmpeg_pid, + "ffmpeg_cpu_pct": round(self._ffmpeg_cpu, 2), + # multi-domain failover info + "active_url_domain": _url_netloc(self._active_url), + "url_count": len(self._stream_urls), + # A/V health + "av_health": self._av_health, + "av_desync_count": self._av_desync_count, + "corrections": self._corrections, + "buffer_pct": self._buffer_pct(), + "buffer_server_bytes": self._buffer_server_bytes(), + "buffer_server_secs": self._buffer_server_secs(), + "buffer_capacity": QUEUE_MAX * CHUNK_SIZE, + } + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + async def _monitor_cpu(self) -> None: + """Update self._ffmpeg_cpu every 3 s using cumulative average since start. + + Delta-based sampling fails for low-CPU processes (copy-mode ffmpeg uses + <1% CPU) because the tick delta in 2 s is 0-1 integer ticks, making the + result always 0.0%. Cumulative average ((total_cpu_ticks / clk_tck) / + elapsed_wall) gives a stable, accurate reading — the same method ps uses. + """ + clk_tck = float(os.sysconf(os.sysconf_names.get("SC_CLK_TCK", 2)) or 100) + + while self._running: + await asyncio.sleep(3) + pid = self._ffmpeg_pid + start_time = self._ffmpeg_start_time + if pid is None or start_time is None: + self._ffmpeg_cpu = 0.0 + continue + try: + with open(f"/proc/{pid}/stat") as f: + parts = f.read().split() + cpu_ticks = int(parts[13]) + int(parts[14]) # utime + stime + elapsed = time.time() - start_time + if elapsed > 0: + self._ffmpeg_cpu = cpu_ticks / clk_tck / elapsed * 100 + except (FileNotFoundError, IndexError, ValueError, OSError): + self._ffmpeg_cpu = 0.0 + + def _buffer_pct(self) -> float: + """Return queue fill % of the slowest (most backlogged) client (0–100).""" + if not self._clients: + return 0.0 + max_fill = max(h.queue.qsize() for h in self._clients.values()) + return round(max_fill / QUEUE_MAX * 100, 1) + + def _buffer_server_bytes(self) -> int: + """Bytes buffered for the least-buffered client (first to run dry on provider drop).""" + if not self._clients: + return 0 + return min(h.queue.qsize() for h in self._clients.values()) * CHUNK_SIZE + + def _buffer_server_secs(self) -> float: + """Estimated seconds of coverage at current bitrate.""" + if not self._clients or self._bps_down <= 0: + return 0.0 + return round(self._buffer_server_bytes() / self._bps_down, 1) + + async def _health_check_loop(self) -> None: + """Update _av_health every 5 s based on data-flow age. + + If ffmpeg is running but producing no output for _HEALTH_ERROR_SECS, + kill it so _pump_with_retry can restart it immediately — this is the + auto-correction the caller sees as a 'corrections' counter tick. + """ + consecutive_error = 0 + while self._running: + await asyncio.sleep(5) + if not self._running: + break + age = time.time() - self._last_data_at + if age < _HEALTH_WARN_SECS: + self._av_health = "ok" + consecutive_error = 0 + elif age < _HEALTH_ERROR_SECS: + self._av_health = "warning" + consecutive_error = 0 + else: + self._av_health = "error" + consecutive_error += 1 + # After 2 consecutive error checks (≈10 s) kill a stuck ffmpeg + # so _pump_with_retry can restart it without waiting for the + # 25-second safety-net timeout in _pump_once. + if consecutive_error >= 2 and self._ffmpeg_pid is not None: + import signal as _sig + try: + os.kill(self._ffmpeg_pid, _sig.SIGTERM) + self._corrections += 1 + logger.warning( + f"[ch{self.channel_id}] Health correction #{self._corrections}: " + f"killed stuck ffmpeg pid={self._ffmpeg_pid} " + f"(no data for {age:.1f}s)" + ) + except (ProcessLookupError, PermissionError): + pass + consecutive_error = 0 # reset so we don't spam kills + + async def _log_stream_event( + self, + event_type: str, + domain: str, + error_message: str, + attempt: int, + ) -> None: + """Persist a stream failure/recovery event to the database (fire-and-forget).""" + try: + from ..database import AsyncSessionLocal + from ..models.stream_event import StreamEvent + async with AsyncSessionLocal() as db: + ev = StreamEvent( + channel_id=self.channel_id, + channel_name=self.channel_name or f"Canal #{self.channel_id}", + provider_name=self.provider_name, + event_type=event_type, + domain=domain, + error_message=error_message[:500] if error_message else None, + attempt_number=attempt, + ) + db.add(ev) + await db.commit() + except Exception as exc: + logger.debug(f"[ch{self.channel_id}] Failed to persist stream event: {exc}") + + async def _probe_info(self) -> None: + from .probe import probe_stream + try: + info = await probe_stream(self.stream_url) + self.stream_info.update(info) + except Exception as e: + logger.debug(f"probe_info failed for ch{self.channel_id}: {e}") + + async def _pump_with_retry(self) -> None: + consecutive = 0 + base_wait = 0.5 + url_idx = 0 + n = len(self._stream_urls) or 1 + + try: + while self._running: + current_url = self._stream_urls[url_idx % n] + self._active_url = current_url + pump_start = time.time() + try: + await self._pump_once(current_url) + url_idx = 0 # reset to primary after successful pump + break + except asyncio.CancelledError: + raise + except Exception as e: + if not self._running: + break + pump_duration = time.time() - pump_start + + # Recovery detection: had failures before but this attempt ran stably + # for >30s — the stream was healthy and dropped again (e.g. provider + # session timeout). Log recovery, then treat next failure as fresh drop. + if consecutive > 1 and pump_duration > 30: + asyncio.create_task(self._log_stream_event( + "recuperado", + _url_netloc(current_url), + f"Estable {int(pump_duration)}s antes de caer de nuevo", + consecutive, + )) + consecutive = 0 # reset so next failure is logged as a fresh "caida" + + consecutive += 1 + self._reconnect_count += 1 + next_idx = (url_idx + 1) % n + cycle_done = (consecutive % n == 0) + + # Log the first drop of each failure sequence + if consecutive == 1: + asyncio.create_task(self._log_stream_event( + "caida", + _url_netloc(current_url), + str(e), + 1, + )) + + if n > 1: + logger.warning( + f"[ch{self.channel_id}] Domain {_url_netloc(current_url)} failed " + f"(attempt {consecutive}), trying {_url_netloc(self._stream_urls[next_idx])}: {e}" + ) + else: + logger.warning( + f"[ch{self.channel_id}] Upstream error (attempt {consecutive}): {e}" + ) + + url_idx = next_idx + self._status = "reconnecting" + + if cycle_done: + # All domains tried — back off before next cycle. + # Do NOT drain client queues: the buffered data (up to 32 MB) covers + # the reconnect window so players never see a black screen. + wait = min(base_wait * (2 ** (consecutive // n - 1)), _BACKOFF_CAP) + logger.warning(f"[ch{self.channel_id}] All {n} domain(s) failed, retrying in {wait:.1f}s") + await asyncio.sleep(wait) + # else: try next domain immediately (no sleep) + finally: + self._status = "stopped" + self._running = False + async with self._lock: + for handle in self._clients.values(): + _drain_queue(handle.queue) + try: + handle.queue.put_nowait(_SENTINEL) + except asyncio.QueueFull: + pass + + async def _pump_once(self, url: str | None = None) -> None: + """ + Spawn ffmpeg in copy (remux) mode and pump its stdout to all client + queues until ffmpeg exits or self._running goes False. + + Reconnection is intentionally NOT delegated to ffmpeg (-reconnect_streamed + is omitted). When the provider closes the TCP session (typically every + ~2 minutes), ffmpeg exits cleanly and _pump_with_retry immediately spawns + a fresh process that opens a brand-new HTTP connection. The provider then + delivers the stream from the current live edge — timestamps are always + forward-moving. If ffmpeg were to reconnect internally it would often + receive a few seconds of already-seen data (the provider rewinds to the + last keyframe boundary), causing a visible backward seek in the player. + """ + stream_url = url if url is not None else self.stream_url + cmd = [ + _FFMPEG, + "-hide_banner", "-loglevel", "warning", + "-timeout", str(int(STALL_TIMEOUT * 1_000_000)), + "-i", stream_url, + "-c", "copy", + "-f", "mpegts", + "pipe:1", + ] + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + self._status = "ok" + self._last_data_at = time.time() + self._ffmpeg_pid = proc.pid + self._ffmpeg_start_time = time.time() + logger.info( + f"[ch{self.channel_id}] ffmpeg started pid={proc.pid} " + f"(reconnects so far: {self._reconnect_count})" + ) + + # Drain stderr in background so the pipe never fills and blocks ffmpeg. + stderr_task = asyncio.create_task(self._log_ffmpeg_stderr(proc)) + + try: + while self._running: + try: + chunk = await asyncio.wait_for( + proc.stdout.read(CHUNK_SIZE), + timeout=STALL_TIMEOUT + 15, # safety net beyond ffmpeg's own timeout + ) + except asyncio.TimeoutError: + raise RuntimeError( + f"ffmpeg produced no output for {STALL_TIMEOUT + 15:.0f}s" + ) + + if not chunk: + # ffmpeg exited cleanly or with error + rc = proc.returncode + raise RuntimeError(f"ffmpeg exited (rc={rc})") + + # Throughput accounting + self._bytes_pumped += len(chunk) + self._last_data_at = time.time() + now = self._last_data_at + if now - self._bps_time >= 2.0: + self._bps_down = (self._bytes_pumped - self._bytes_prev) / (now - self._bps_time) + self._bytes_prev = self._bytes_pumped + self._bps_time = now + + await self._dispatch(chunk, now) + + finally: + self._ffmpeg_pid = None + self._ffmpeg_start_time = None + self._ffmpeg_cpu = 0.0 + stderr_task.cancel() + try: + proc.kill() + except ProcessLookupError: + pass + try: + await asyncio.wait_for(proc.wait(), timeout=5) + except asyncio.TimeoutError: + pass + + async def _log_ffmpeg_stderr(self, proc: asyncio.subprocess.Process) -> None: + """Read ffmpeg stderr lines, log them and count A/V-quality events.""" + try: + async for line in proc.stderr: + text = line.decode(errors="replace").rstrip() + if text: + logger.warning(f"[ch{self.channel_id}] ffmpeg: {text}") + if any(p in text for p in _AV_WARN_PATTERNS): + self._av_desync_count += 1 + except asyncio.CancelledError: + pass + except Exception: + pass + + async def _dispatch(self, chunk: bytes, now: float) -> None: + """Push chunk to every client queue; evict clients that have been full for >30 s.""" + async with self._lock: + dead: list[str] = [] + for cid, handle in self._clients.items(): + try: + handle.queue.put_nowait(chunk) + handle._overflow_count = 0 + handle._overflow_since = None + except asyncio.QueueFull: + handle._overflow_count += 1 + if handle._overflow_since is None: + handle._overflow_since = now + elif now - handle._overflow_since > 30: + dead.append(cid) + + for cid in dead: + logger.warning( + f"[ch{self.channel_id}] Evicting unresponsive client {cid} " + f"after 30 s of sustained queue overflow" + ) + dropped = self._clients.pop(cid, None) + if dropped is not None: + _drain_queue(dropped.queue) + try: + dropped.queue.put_nowait(_SENTINEL) + except asyncio.QueueFull: + pass + + +def _drain_queue(q: asyncio.Queue) -> None: + """Empty a queue non-blockingly.""" + while not q.empty(): + try: + q.get_nowait() + except asyncio.QueueEmpty: + break diff --git a/backend/core/vod_tracker.py b/backend/core/vod_tracker.py new file mode 100644 index 0000000..000f2eb --- /dev/null +++ b/backend/core/vod_tracker.py @@ -0,0 +1,164 @@ +"""VodTracker: lightweight session tracking for VOD and Series streams.""" +import asyncio +import logging +import time +import uuid +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +STALE_TIMEOUT = 10 # seconds of no new bytes before a session is considered dead + + +@dataclass +class VodSession: + session_id: str + user_id: str + username: str + channel_id: int + channel_name: str + provider_account_id: int + provider_name: str + stream_type: str # "movie" or "series" + stream_url: str + started_at: float = field(default_factory=time.time) + last_active: float = field(default_factory=time.time) + bytes_pumped: int = 0 + bps_down: float = 0.0 # download speed from provider + bps_up: float = 0.0 # upload speed to client (same as down for VOD, 1 client) + running: bool = True + _stop: asyncio.Event = field(default_factory=asyncio.Event) + # Set by _proxy_vod so kill() can immediately abort the upstream connection + _http_session: object = field(default=None, repr=False) + + @property + def should_stop(self) -> bool: + return self._stop.is_set() + + def kill(self) -> None: + self._stop.set() + # Close the upstream aiohttp session immediately so iter_chunked() raises + # and the generator's finally block runs without waiting for the next chunk. + http = self._http_session + if http is not None: + try: + http.connector.close() + except Exception: + pass + + def to_dict(self) -> dict: + return { + "stream_type": self.stream_type, + "channel_id": self.channel_id, + "channel_name": self.channel_name, + "provider_account_id": self.provider_account_id, + "provider_name": self.provider_name, + "client_count": 1, + "clients": [{"client_id": self.session_id, "username": self.username}], + "started_at": self.started_at, + "bytes_pumped": self.bytes_pumped, + "bps_down": self.bps_down, + "bps_up": self.bps_up, + "running": self.running, + } + + +class VodTracker: + def __init__(self) -> None: + self._sessions: dict[str, VodSession] = {} + self._user_channel_map: dict[tuple[str, int], str] = {} + self._cleanup_task: asyncio.Task | None = None + + def _ensure_cleanup_running(self) -> None: + """Start the background staleness sweep if not already running.""" + if self._cleanup_task is None or self._cleanup_task.done(): + try: + loop = asyncio.get_running_loop() + self._cleanup_task = loop.create_task(self._cleanup_loop(), name="vod-cleanup") + except RuntimeError: + pass # no event loop yet — task will start on first session + + async def _cleanup_loop(self) -> None: + """Every 5 s, remove sessions that have received no new bytes for STALE_TIMEOUT s.""" + while True: + await asyncio.sleep(5) + now = time.time() + stale = [ + s for s in list(self._sessions.values()) + if now - s.last_active > STALE_TIMEOUT + ] + for s in stale: + logger.info( + f"Removing stale VOD session {s.session_id} " + f"(user={s.user_id}, ch={s.channel_id}, " + f"idle={now - s.last_active:.0f}s)" + ) + s.kill() + self._sessions.pop(s.session_id, None) + key = (s.user_id, s.channel_id) + if self._user_channel_map.get(key) == s.session_id: + self._user_channel_map.pop(key, None) + + def start( + self, + user_id: str, + username: str, + channel_id: int, + channel_name: str, + provider_account_id: int, + provider_name: str, + stream_type: str, + stream_url: str, + user_max_connections: int = 1, + ) -> VodSession: + self._ensure_cleanup_running() + + # Kill ALL existing VOD sessions for this user — one active VOD per user. + # Handles both probe-zombies (same channel) and movie switching (different channel). + for s in list(self._sessions.values()): + if s.user_id == user_id: + self._sessions.pop(s.session_id, None) + old_key = (s.user_id, s.channel_id) + if self._user_channel_map.get(old_key) == s.session_id: + self._user_channel_map.pop(old_key, None) + s.kill() + logger.info( + f"Killed VOD session {s.session_id} (ch={s.channel_id}) " + f"for user={user_id} — new session starting" + ) + + session_id = f"vod-{uuid.uuid4().hex[:8]}" + session = VodSession( + session_id=session_id, + user_id=user_id, + username=username, + channel_id=channel_id, + channel_name=channel_name, + provider_account_id=provider_account_id, + provider_name=provider_name, + stream_type=stream_type, + stream_url=stream_url, + ) + self._sessions[session_id] = session + self._user_channel_map[(user_id, channel_id)] = session_id + return session + + def end(self, session_id: str) -> None: + session = self._sessions.pop(session_id, None) + if session: + key = (session.user_id, session.channel_id) + if self._user_channel_map.get(key) == session_id: + self._user_channel_map.pop(key, None) + + def kill(self, session_id: str) -> bool: + s = self._sessions.get(session_id) + if s: + s.kill() + return True + return False + + def get_all(self) -> list[dict]: + return [s.to_dict() for s in self._sessions.values()] + + +vod_tracker = VodTracker() diff --git a/backend/core/xtream_client.py b/backend/core/xtream_client.py new file mode 100644 index 0000000..dc8985f --- /dev/null +++ b/backend/core/xtream_client.py @@ -0,0 +1,87 @@ +"""Client for consuming Xtream Codes API from provider.""" +import aiohttp +import asyncio +from typing import Any +import logging + +logger = logging.getLogger(__name__) + + +class XtreamClient: + def __init__(self, base_url: str, username: str, password: str): + self.base_url = base_url.rstrip("/") + self.username = username + self.password = password + + def _api_url(self) -> str: + return f"{self.base_url}/player_api.php" + + async def _get(self, params: dict) -> Any: + params = {"username": self.username, "password": self.password, **params} + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(self._api_url(), params=params, ssl=False) as resp: + resp.raise_for_status() + return await resp.json(content_type=None) + + async def get_user_info(self) -> dict: + return await self._get({}) + + async def get_live_categories(self) -> list[dict]: + return await self._get({"action": "get_live_categories"}) + + async def get_live_streams(self, category_id: str | None = None) -> list[dict]: + params = {"action": "get_live_streams"} + if category_id: + params["category_id"] = category_id + return await self._get(params) + + async def get_vod_categories(self) -> list[dict]: + return await self._get({"action": "get_vod_categories"}) + + async def get_vod_streams(self, category_id: str | None = None) -> list[dict]: + params = {"action": "get_vod_streams"} + if category_id: + params["category_id"] = category_id + return await self._get(params) + + async def get_series_categories(self) -> list[dict]: + return await self._get({"action": "get_series_categories"}) + + async def get_series(self, category_id: str | None = None) -> list[dict]: + params = {"action": "get_series"} + if category_id: + params["category_id"] = category_id + return await self._get(params) + + async def get_series_info(self, series_id: str) -> dict: + return await self._get({"action": "get_series_info", "series_id": series_id}) + + async def get_vod_info(self, vod_id: str) -> dict: + return await self._get({"action": "get_vod_info", "vod_id": vod_id}) + + async def get_epg(self, stream_id: str | None = None) -> dict: + params = {"action": "get_short_epg"} + if stream_id: + params["stream_id"] = stream_id + return await self._get(params) + + def build_live_stream_url(self, stream_id: str, ext: str = "ts") -> str: + return f"{self.base_url}/{self.username}/{self.password}/{stream_id}.{ext}" + + def build_vod_stream_url(self, stream_id: str, ext: str = "mkv") -> str: + return f"{self.base_url}/movie/{self.username}/{self.password}/{stream_id}.{ext}" + + def build_series_stream_url(self, stream_id: str, ext: str = "mkv") -> str: + return f"{self.base_url}/series/{self.username}/{self.password}/{stream_id}.{ext}" + + def get_xmltv_url(self) -> str: + return f"{self.base_url}/xmltv.php?username={self.username}&password={self.password}" + + async def test_connection(self) -> bool: + try: + info = await self.get_user_info() + return info.get("user_info", {}).get("status") == "Active" + except Exception as e: + logger.warning(f"Provider connection test failed: {e}") + return False diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..b9e1e72 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,42 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import text +from .config import settings + +engine = create_async_engine( + settings.database_url, + echo=False, + pool_size=20, + max_overflow=10, + pool_pre_ping=True, +) + +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + +class Base(DeclarativeBase): + pass + + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() + + +async def init_db(): + from .models import provider, channel, user, epg, custom_category, log, notification, jellyfin, stream_event # noqa: ensure all models imported + from .models.provider import ProviderUrl # noqa: ensure ProviderUrl is registered + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + # Migrate: create provider_urls entries from existing base_url if not already done + await conn.execute(text(""" + INSERT INTO provider_urls (provider_account_id, url, priority, is_active, status) + SELECT id, base_url, 0, true, 'unknown' + FROM provider_accounts + WHERE id NOT IN (SELECT DISTINCT provider_account_id FROM provider_urls WHERE provider_account_id IS NOT NULL) + ON CONFLICT DO NOTHING + """)) + await conn.commit() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..e5978cd --- /dev/null +++ b/backend/main.py @@ -0,0 +1,143 @@ +"""KiraStream — IPTV Management Server.""" +import asyncio +import logging +import sys +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Depends +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.security import OAuth2PasswordRequestForm + +import json +from .config import settings +from .database import init_db, AsyncSessionLocal +from .core.pool import pool +from .core.epg_manager import epg_background_task +from .core.health_checker import health_check_loop +from .database import get_db + +logging.basicConfig( + level=getattr(logging, settings.log_level), + format="%(asctime)s %(levelname)s %(name)s — %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) +logger = logging.getLogger(__name__) + + +async def _auto_sync_loop() -> None: + """Check every 30 minutes if any provider is due for auto-sync.""" + from sqlalchemy import select + from .models.provider import ProviderAccount + from .api.admin.providers import _run_sync, _sync_status + + await asyncio.sleep(60) # delay first check so startup completes + while True: + try: + async with AsyncSessionLocal() as db: + result = await db.execute( + select(ProviderAccount).where(ProviderAccount.auto_sync_hours > 0, ProviderAccount.is_active == True) # noqa: E712 + ) + providers = result.scalars().all() + now = asyncio.get_event_loop().time() + import time as _time + wall_now = _time.time() + for p in providers: + interval_secs = p.auto_sync_hours * 3600 + last_sync_ts = p.last_sync_at.timestamp() if p.last_sync_at else 0 + if wall_now - last_sync_ts >= interval_secs: + if _sync_status.get(p.id, {}).get("status") == "running": + continue + logger.info(f"Auto-sync triggered for provider {p.id} ({p.name})") + cats = json.loads(p.last_sync_categories) if p.last_sync_categories else {} + asyncio.create_task(_run_sync( + p.id, p.base_url, p.username, p.password, + selected_live=set(cats["live"]) if cats.get("live") is not None else None, + selected_movie=set(cats["movie"]) if cats.get("movie") is not None else None, + selected_series=set(cats["series"]) if cats.get("series") is not None else None, + )) + except Exception as e: + logger.error(f"Auto-sync loop error: {e}") + await asyncio.sleep(1800) # check every 30 minutes + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("KiraStream starting up...") + await init_db() + await pool.init() + epg_task = asyncio.create_task(epg_background_task(get_db)) + auto_sync_task = asyncio.create_task(_auto_sync_loop()) + health_task = asyncio.create_task(health_check_loop()) + logger.info("KiraStream ready") + yield + logger.info("KiraStream shutting down...") + await pool.close() + epg_task.cancel() + auto_sync_task.cancel() + health_task.cancel() + for t in (epg_task, auto_sync_task, health_task): + try: + await t + except asyncio.CancelledError: + pass + + +app = FastAPI( + title="KiraStream", + description="Professional IPTV Management with Xtream Codes", + version="1.0.0", + lifespan=lifespan, + docs_url="/api/docs", + redoc_url="/api/redoc", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# --- Admin auth --- +from .api.auth import login, Token # noqa: E402 + +@app.post("/api/admin/auth/token", response_model=Token, tags=["admin"]) +async def admin_login(form_data: OAuth2PasswordRequestForm = Depends()): + return await login(form_data) + +# --- Admin routers --- +from .api.admin.providers import router as providers_router # noqa: E402 +from .api.admin.users import router as users_router # noqa: E402 +from .api.admin.catalogs import router as catalogs_router # noqa: E402 +from .api.admin.epg_admin import router as epg_admin_router # noqa: E402 +from .api.admin.monitoring import router as monitoring_router # noqa: E402 +from .api.admin.custom_categories import router as custom_cats_router # noqa: E402 +from .api.admin.logs import router as logs_router # noqa: E402 +from .api.admin.settings import router as settings_router # noqa: E402 +from .api.admin.jellyfin_admin import router as jellyfin_router # noqa: E402 + +app.include_router(providers_router, prefix="/api/admin") +app.include_router(users_router, prefix="/api/admin") +app.include_router(catalogs_router, prefix="/api/admin") +app.include_router(epg_admin_router, prefix="/api/admin") +app.include_router(monitoring_router, prefix="/api/admin") +app.include_router(custom_cats_router, prefix="/api/admin") +app.include_router(logs_router, prefix="/api/admin") +app.include_router(settings_router, prefix="/api/admin") +app.include_router(jellyfin_router, prefix="/api/admin") + +# --- Xtream Codes compatible API (public) --- +from .api.xtream import router as xtream_router # noqa: E402 +app.include_router(xtream_router) + + +@app.get("/health") +async def health(): + return {"status": "ok", "service": "kirastream"} + + +@app.get("/") +async def root(): + return JSONResponse({"message": "KiraStream IPTV Server", "docs": "/api/docs"}) diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..4c9aad6 --- /dev/null +++ b/backend/models/__init__.py @@ -0,0 +1,15 @@ +from .provider import ProviderAccount +from .channel import Category, Channel, ChannelProviderMap, ContentType +from .user import User, UserCatalogEntry +from .epg import EpgSource + +__all__ = [ + "ProviderAccount", + "Category", + "Channel", + "ChannelProviderMap", + "ContentType", + "User", + "UserCatalogEntry", + "EpgSource", +] diff --git a/backend/models/channel.py b/backend/models/channel.py new file mode 100644 index 0000000..3aae609 --- /dev/null +++ b/backend/models/channel.py @@ -0,0 +1,61 @@ +import enum +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum, UniqueConstraint, func +from sqlalchemy.orm import relationship +from ..database import Base + + +class ContentType(str, enum.Enum): + live = "live" + movie = "movie" + series = "series" + + +class Category(Base): + __tablename__ = "categories" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(300), nullable=False) + type = Column(Enum(ContentType), nullable=False) + provider_category_id = Column(String(100), nullable=True) + provider_account_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + channels = relationship("Channel", back_populates="category") + catalog_entries = relationship("UserCatalogEntry", back_populates="category", cascade="all, delete-orphan") + + __table_args__ = ( + UniqueConstraint("provider_category_id", "provider_account_id", "type", name="uq_category_provider"), + ) + + +class Channel(Base): + __tablename__ = "channels" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(500), nullable=False) + tvg_id = Column(String(200), nullable=True, index=True) + tvg_logo = Column(String(1000), nullable=True) + stream_id_at_provider = Column(String(100), nullable=False) + category_id = Column(Integer, ForeignKey("categories.id", ondelete="SET NULL"), nullable=True) + type = Column(Enum(ContentType), nullable=False, default=ContentType.live) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + category = relationship("Category", back_populates="channels") + provider_maps = relationship("ChannelProviderMap", back_populates="channel", cascade="all, delete-orphan") + + +class ChannelProviderMap(Base): + __tablename__ = "channel_provider_map" + + id = Column(Integer, primary_key=True, index=True) + channel_id = Column(Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=False) + provider_account_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False) + stream_url = Column(String(2000), nullable=False) + + channel = relationship("Channel", back_populates="provider_maps") + provider_account = relationship("ProviderAccount", back_populates="channel_maps") + + __table_args__ = ( + UniqueConstraint("channel_id", "provider_account_id", name="uq_channel_provider"), + ) diff --git a/backend/models/custom_category.py b/backend/models/custom_category.py new file mode 100644 index 0000000..b885bcf --- /dev/null +++ b/backend/models/custom_category.py @@ -0,0 +1,29 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum, ForeignKey, func +from sqlalchemy.orm import relationship +from ..database import Base +from .channel import ContentType + + +class CustomCategory(Base): + __tablename__ = "custom_categories" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), nullable=False) + type = Column(Enum(ContentType), nullable=False) + position = Column(Integer, default=0, nullable=False) + is_visible = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + items = relationship("CustomCategoryItem", back_populates="category", cascade="all, delete-orphan") + + +class CustomCategoryItem(Base): + __tablename__ = "custom_category_items" + + id = Column(Integer, primary_key=True, index=True) + custom_category_id = Column(Integer, ForeignKey("custom_categories.id"), nullable=False) + channel_id = Column(Integer, ForeignKey("channels.id"), nullable=False) + position = Column(Integer, default=0, nullable=False) + + category = relationship("CustomCategory", back_populates="items") + channel = relationship("Channel") diff --git a/backend/models/epg.py b/backend/models/epg.py new file mode 100644 index 0000000..7b60d70 --- /dev/null +++ b/backend/models/epg.py @@ -0,0 +1,13 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, func +from ..database import Base + + +class EpgSource(Base): + __tablename__ = "epg_sources" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), nullable=False) + url = Column(String(2000), nullable=False) + last_fetched_at = Column(DateTime(timezone=True), nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/models/jellyfin.py b/backend/models/jellyfin.py new file mode 100644 index 0000000..90e2203 --- /dev/null +++ b/backend/models/jellyfin.py @@ -0,0 +1,47 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, func +from sqlalchemy.orm import relationship +from ..database import Base + + +class JellyfinConfig(Base): + __tablename__ = "jellyfin_configs" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), nullable=False) + url = Column(String(500), nullable=False) + api_key = Column(String(300), nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + movies_library_id = Column(String(100), nullable=True) + series_library_id = Column(String(100), nullable=True) + movies_category_name = Column(String(200), default="Películas KiraTV", nullable=False) + series_category_name = Column(String(200), default="Series KiraTV", nullable=False) + last_sync_at = Column(DateTime(timezone=True), nullable=True) + # Dummy ProviderAccount auto-created to satisfy ChannelProviderMap FK + provider_account_id = Column( + Integer, + ForeignKey("provider_accounts.id", ondelete="SET NULL"), + nullable=True, + ) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + items = relationship("JellyfinItem", back_populates="config", cascade="all, delete-orphan") + + +class JellyfinItem(Base): + __tablename__ = "jellyfin_items" + + id = Column(Integer, primary_key=True, index=True) + jellyfin_config_id = Column( + Integer, ForeignKey("jellyfin_configs.id", ondelete="CASCADE"), nullable=False + ) + channel_id = Column( + Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=True + ) + jellyfin_item_id = Column(String(100), nullable=False, index=True) # Jellyfin hex ID + item_type = Column(String(20), nullable=False) # "movie" | "series" + plot = Column(Text, nullable=True) + year = Column(Integer, nullable=True) + genres = Column(String(500), nullable=True) + rating = Column(String(20), nullable=True) + + config = relationship("JellyfinConfig", back_populates="items") diff --git a/backend/models/log.py b/backend/models/log.py new file mode 100644 index 0000000..4cb913b --- /dev/null +++ b/backend/models/log.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, Integer, BigInteger, String, DateTime, func +from ..database import Base + + +class ConnectionLog(Base): + __tablename__ = "connection_logs" + + id = Column(BigInteger, primary_key=True, index=True) + user_id = Column(Integer, nullable=False, index=True) + username = Column(String(200), nullable=False, index=True) + channel_id = Column(Integer, nullable=True) + channel_name = Column(String(500), nullable=True) + content_type = Column(String(20), nullable=True) # live, movie, series + stream_id = Column(String(100), nullable=True) + started_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) + ended_at = Column(DateTime(timezone=True), nullable=True) + duration_seconds = Column(Integer, nullable=True) + bytes_transferred = Column(BigInteger, default=0, nullable=False) + client_ip = Column(String(100), nullable=True) diff --git a/backend/models/notification.py b/backend/models/notification.py new file mode 100644 index 0000000..2d477ae --- /dev/null +++ b/backend/models/notification.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, UniqueConstraint, func +from ..database import Base + + +class NotificationDismissal(Base): + __tablename__ = "notification_dismissals" + + id = Column(Integer, primary_key=True, index=True) + provider_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False) + category_id_str = Column(String(100), nullable=False) + category_type = Column(String(20), nullable=False) + dismissed_at = Column(DateTime(timezone=True), server_default=func.now()) + + __table_args__ = ( + UniqueConstraint("provider_id", "category_id_str", "category_type", name="uq_dismissal"), + ) diff --git a/backend/models/provider.py b/backend/models/provider.py new file mode 100644 index 0000000..4f46a65 --- /dev/null +++ b/backend/models/provider.py @@ -0,0 +1,41 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, func +from sqlalchemy.orm import relationship +from ..database import Base + + +class ProviderAccount(Base): + __tablename__ = "provider_accounts" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + base_url = Column(String(500), nullable=False) + username = Column(String(200), nullable=False) + password = Column(String(200), nullable=False) + max_connections = Column(Integer, default=1, nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + last_sync_at = Column(DateTime(timezone=True), nullable=True) + expiry_date = Column(DateTime(timezone=True), nullable=True) + # 0 = disabled; 6/12/24 = sync every N hours automatically + auto_sync_hours = Column(Integer, default=0, nullable=False) + # JSON: {"live": ["cat_id", ...], "movie": [...], "series": [...]} + last_sync_categories = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + channel_maps = relationship("ChannelProviderMap", back_populates="provider_account", cascade="all, delete-orphan") + extra_urls = relationship("ProviderUrl", back_populates="provider_account", cascade="all, delete-orphan", order_by="ProviderUrl.priority") + + +class ProviderUrl(Base): + __tablename__ = "provider_urls" + + id = Column(Integer, primary_key=True, index=True) + provider_account_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False, index=True) + url = Column(String(500), nullable=False) + priority = Column(Integer, default=0, nullable=False) # 0=primary, higher=lower priority + is_active = Column(Boolean, default=True, nullable=False) + status = Column(String(20), default="unknown", nullable=False) # unknown|ok|error|timeout + response_ms = Column(Integer, nullable=True) + last_checked_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + provider_account = relationship("ProviderAccount", back_populates="extra_urls") diff --git a/backend/models/stream_event.py b/backend/models/stream_event.py new file mode 100644 index 0000000..70bdc9a --- /dev/null +++ b/backend/models/stream_event.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, BigInteger, Integer, String, Text, DateTime, func +from ..database import Base + + +class StreamEvent(Base): + __tablename__ = "stream_events" + + id = Column(BigInteger, primary_key=True, index=True) + channel_id = Column(Integer, nullable=True, index=True) + channel_name = Column(String(500), nullable=True) + provider_name = Column(String(200), nullable=True) + event_type = Column(String(50), nullable=False, index=True) # caida | recuperado + domain = Column(String(300), nullable=True) + error_message = Column(Text, nullable=True) + attempt_number = Column(Integer, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) diff --git a/backend/models/user.py b/backend/models/user.py new file mode 100644 index 0000000..691966f --- /dev/null +++ b/backend/models/user.py @@ -0,0 +1,66 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, func +from sqlalchemy.orm import relationship +import bcrypt as _bcrypt +from ..database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(100), unique=True, nullable=False, index=True) + password_hash = Column(String(200), nullable=False) + max_connections = Column(Integer, default=1, nullable=False) + expiry_date = Column(DateTime(timezone=True), nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + is_priority = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + catalog_entries = relationship("UserCatalogEntry", back_populates="user", cascade="all, delete-orphan") + provider_preferences = relationship("UserProviderPreference", back_populates="user", cascade="all, delete-orphan", order_by="UserProviderPreference.priority") + + def set_password(self, password: str) -> None: + hashed = _bcrypt.hashpw(password.encode(), _bcrypt.gensalt()) + self.password_hash = hashed.decode() + + def verify_password(self, password: str) -> bool: + return _bcrypt.checkpw(password.encode(), self.password_hash.encode()) + + +class UserCatalogEntry(Base): + __tablename__ = "user_catalog_entries" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + category_id = Column(Integer, ForeignKey("categories.id", ondelete="CASCADE"), nullable=False) + + user = relationship("User", back_populates="catalog_entries") + category = relationship("Category", back_populates="catalog_entries") + + +class UserCustomCatalogEntry(Base): + __tablename__ = "user_custom_catalog_entries" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + custom_category_id = Column(Integer, ForeignKey("custom_categories.id", ondelete="CASCADE"), nullable=False) + + __table_args__ = ( + UniqueConstraint("user_id", "custom_category_id", name="uq_user_custom_cat"), + ) + + +class UserProviderPreference(Base): + __tablename__ = "user_provider_preferences" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + provider_account_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False) + priority = Column(Integer, default=0, nullable=False) + + user = relationship("User", back_populates="provider_preferences") + provider_account = relationship("ProviderAccount") + + __table_args__ = ( + UniqueConstraint("user_id", "provider_account_id", name="uq_user_provider_pref"), + ) diff --git a/backend/static/assets/index-Bf2TNXpE.css b/backend/static/assets/index-Bf2TNXpE.css new file mode 100644 index 0000000..41c110e --- /dev/null +++ b/backend/static/assets/index-Bf2TNXpE.css @@ -0,0 +1 @@ +*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1));font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}::-webkit-scrollbar-thumb{border-radius:.25rem;--tw-bg-opacity: 1;background-color:rgb(71 85 105 / var(--tw-bg-opacity, 1))}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.-right-1{right:-.25rem}.-top-1{top:-.25rem}.left-0{left:0}.left-2\.5{left:.625rem}.left-3{left:.75rem}.top-1\/2{top:50%}.top-full{top:100%}.z-50{z-index:50}.col-span-2{grid-column:span 2 / span 2}.col-span-full{grid-column:1 / -1}.mx-auto{margin-left:auto;margin-right:auto}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-2{height:.5rem}.h-4{height:1rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-48{max-height:12rem}.max-h-64{max-height:16rem}.max-h-72{max-height:18rem}.max-h-\[70vh\]{max-height:70vh}.max-h-\[80vh\]{max-height:80vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.min-h-screen{min-height:100vh}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-2{width:.5rem}.w-36{width:9rem}.w-4{width:1rem}.w-48{width:12rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-72{width:18rem}.w-8{width:2rem}.w-80{width:20rem}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[140px\]{min-width:140px}.min-w-\[160px\]{min-width:160px}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-\[160px\]{max-width:160px}.max-w-\[180px\]{max-width:180px}.max-w-\[240px\]{max-width:240px}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.self-center{align-self:center}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-xl{border-radius:.75rem}.rounded-t-lg{border-top-left-radius:.5rem;border-top-right-radius:.5rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-blue-800{--tw-border-opacity: 1;border-color:rgb(30 64 175 / var(--tw-border-opacity, 1))}.border-cyan-800{--tw-border-opacity: 1;border-color:rgb(21 94 117 / var(--tw-border-opacity, 1))}.border-dark-600{--tw-border-opacity: 1;border-color:rgb(71 85 105 / var(--tw-border-opacity, 1))}.border-dark-700{--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.border-dark-700\/50{border-color:#33415580}.border-green-800{--tw-border-opacity: 1;border-color:rgb(22 101 52 / var(--tw-border-opacity, 1))}.border-orange-700{--tw-border-opacity: 1;border-color:rgb(194 65 12 / var(--tw-border-opacity, 1))}.border-orange-800{--tw-border-opacity: 1;border-color:rgb(154 52 18 / var(--tw-border-opacity, 1))}.border-primary-500{--tw-border-opacity: 1;border-color:rgb(59 91 219 / var(--tw-border-opacity, 1))}.border-primary-500\/20{border-color:#3b5bdb33}.border-primary-700{--tw-border-opacity: 1;border-color:rgb(44 68 168 / var(--tw-border-opacity, 1))}.border-purple-800{--tw-border-opacity: 1;border-color:rgb(107 33 168 / var(--tw-border-opacity, 1))}.border-red-500{--tw-border-opacity: 1;border-color:rgb(239 68 68 / var(--tw-border-opacity, 1))}.border-red-700{--tw-border-opacity: 1;border-color:rgb(185 28 28 / var(--tw-border-opacity, 1))}.border-red-800{--tw-border-opacity: 1;border-color:rgb(153 27 27 / var(--tw-border-opacity, 1))}.border-transparent{border-color:transparent}.border-violet-800{--tw-border-opacity: 1;border-color:rgb(91 33 182 / var(--tw-border-opacity, 1))}.border-yellow-800{--tw-border-opacity: 1;border-color:rgb(133 77 14 / var(--tw-border-opacity, 1))}.bg-black\/40{background-color:#0006}.bg-black\/60{background-color:#0009}.bg-black\/70{background-color:#000000b3}.bg-blue-500\/10{background-color:#3b82f61a}.bg-blue-500\/20{background-color:#3b82f633}.bg-blue-900\/30{background-color:#1e3a8a4d}.bg-blue-900\/40{background-color:#1e3a8a66}.bg-cyan-900\/40{background-color:#164e6366}.bg-dark-600{--tw-bg-opacity: 1;background-color:rgb(71 85 105 / var(--tw-bg-opacity, 1))}.bg-dark-700{--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity, 1))}.bg-dark-700\/40{background-color:#33415566}.bg-dark-800{--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.bg-dark-900{--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.bg-dark-900\/40{background-color:#0f172a66}.bg-dark-900\/50{background-color:#0f172a80}.bg-dark-900\/80{background-color:#0f172acc}.bg-emerald-500{--tw-bg-opacity: 1;background-color:rgb(16 185 129 / var(--tw-bg-opacity, 1))}.bg-emerald-500\/20{background-color:#10b98133}.bg-gray-700{--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.bg-green-400{--tw-bg-opacity: 1;background-color:rgb(74 222 128 / var(--tw-bg-opacity, 1))}.bg-green-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-green-900\/20{background-color:#14532d33}.bg-green-900\/30{background-color:#14532d4d}.bg-green-900\/40{background-color:#14532d66}.bg-green-900\/50{background-color:#14532d80}.bg-orange-500\/20{background-color:#f9731633}.bg-orange-900\/30{background-color:#7c2d124d}.bg-orange-900\/40{background-color:#7c2d1266}.bg-primary-500{--tw-bg-opacity: 1;background-color:rgb(59 91 219 / var(--tw-bg-opacity, 1))}.bg-primary-500\/10{background-color:#3b5bdb1a}.bg-primary-500\/20{background-color:#3b5bdb33}.bg-primary-600{--tw-bg-opacity: 1;background-color:rgb(52 81 199 / var(--tw-bg-opacity, 1))}.bg-primary-900\/20{background-color:#1a2a6b33}.bg-primary-900\/40{background-color:#1a2a6b66}.bg-purple-500\/20{background-color:#a855f733}.bg-purple-900\/30{background-color:#581c874d}.bg-purple-900\/40{background-color:#581c8766}.bg-red-400{--tw-bg-opacity: 1;background-color:rgb(248 113 113 / var(--tw-bg-opacity, 1))}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-red-500\/20{background-color:#ef444433}.bg-red-900\/20{background-color:#7f1d1d33}.bg-red-900\/30{background-color:#7f1d1d4d}.bg-red-900\/40{background-color:#7f1d1d66}.bg-violet-900\/40{background-color:#4c1d9566}.bg-yellow-400{--tw-bg-opacity: 1;background-color:rgb(250 204 21 / var(--tw-bg-opacity, 1))}.bg-yellow-500{--tw-bg-opacity: 1;background-color:rgb(234 179 8 / var(--tw-bg-opacity, 1))}.bg-yellow-900\/20{background-color:#713f1233}.bg-yellow-900\/40{background-color:#713f1266}.object-contain{-o-object-fit:contain;object-fit:contain}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-10{padding:2.5rem}.p-12{padding:3rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-0{padding-bottom:0}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-8{padding-left:2rem}.pl-9{padding-left:2.25rem}.pr-3{padding-right:.75rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-\[10px\]{font-size:10px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-black{--tw-text-opacity: 1;color:rgb(0 0 0 / var(--tw-text-opacity, 1))}.text-blue-300{--tw-text-opacity: 1;color:rgb(147 197 253 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-cyan-300{--tw-text-opacity: 1;color:rgb(103 232 249 / var(--tw-text-opacity, 1))}.text-cyan-400{--tw-text-opacity: 1;color:rgb(34 211 238 / var(--tw-text-opacity, 1))}.text-emerald-400{--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.text-emerald-500{--tw-text-opacity: 1;color:rgb(16 185 129 / var(--tw-text-opacity, 1))}.text-gray-200{--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity, 1))}.text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.text-gray-700{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity, 1))}.text-green-300{--tw-text-opacity: 1;color:rgb(134 239 172 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-green-500{--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.text-orange-300{--tw-text-opacity: 1;color:rgb(253 186 116 / var(--tw-text-opacity, 1))}.text-orange-400{--tw-text-opacity: 1;color:rgb(251 146 60 / var(--tw-text-opacity, 1))}.text-primary-500{--tw-text-opacity: 1;color:rgb(59 91 219 / var(--tw-text-opacity, 1))}.text-purple-300{--tw-text-opacity: 1;color:rgb(216 180 254 / var(--tw-text-opacity, 1))}.text-purple-400{--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.text-red-300{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-violet-300{--tw-text-opacity: 1;color:rgb(196 181 253 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-yellow-300{--tw-text-opacity: 1;color:rgb(253 224 71 / var(--tw-text-opacity, 1))}.text-yellow-400{--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity, 1))}.text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.opacity-0{opacity:0}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-70{opacity:.7}.shadow-2xl{--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / .25);--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.\!filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)!important}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-700{transition-duration:.7s}.hover\:bg-dark-600:hover{--tw-bg-opacity: 1;background-color:rgb(71 85 105 / var(--tw-bg-opacity, 1))}.hover\:bg-dark-700:hover{--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity, 1))}.hover\:bg-dark-700\/30:hover{background-color:#3341554d}.hover\:bg-dark-700\/50:hover{background-color:#33415580}.hover\:bg-primary-500:hover{--tw-bg-opacity: 1;background-color:rgb(59 91 219 / var(--tw-bg-opacity, 1))}.hover\:bg-primary-500\/30:hover{background-color:#3b5bdb4d}.hover\:bg-primary-600:hover{--tw-bg-opacity: 1;background-color:rgb(52 81 199 / var(--tw-bg-opacity, 1))}.hover\:bg-primary-700:hover{--tw-bg-opacity: 1;background-color:rgb(44 68 168 / var(--tw-bg-opacity, 1))}.hover\:bg-primary-900\/30:hover{background-color:#1a2a6b4d}.hover\:bg-primary-900\/70:hover{background-color:#1a2a6bb3}.hover\:bg-red-500\/30:hover{background-color:#ef44444d}.hover\:bg-red-700:hover{--tw-bg-opacity: 1;background-color:rgb(185 28 28 / var(--tw-bg-opacity, 1))}.hover\:bg-red-900\/40:hover{background-color:#7f1d1d66}.hover\:bg-yellow-900\/30:hover{background-color:#713f124d}.hover\:text-blue-400:hover{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.hover\:text-gray-100:hover{--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.hover\:text-gray-300:hover{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.hover\:text-green-400:hover{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.hover\:text-purple-400:hover{--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.hover\:text-red-400:hover{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:text-yellow-400:hover{--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity, 1))}.hover\:opacity-100:hover{opacity:1}.focus\:border-primary-500:focus{--tw-border-opacity: 1;border-color:rgb(59 91 219 / var(--tw-border-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:opacity-100{opacity:1}@media(min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}}@media(min-width:1280px){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} diff --git a/backend/static/assets/index-H1dlHjcY.js b/backend/static/assets/index-H1dlHjcY.js new file mode 100644 index 0000000..20f3787 --- /dev/null +++ b/backend/static/assets/index-H1dlHjcY.js @@ -0,0 +1,278 @@ +function Im(s,a){for(var i=0;ic[d]})}}}return Object.freeze(Object.defineProperty(s,Symbol.toStringTag,{value:"Module"}))}(function(){const a=document.createElement("link").relList;if(a&&a.supports&&a.supports("modulepreload"))return;for(const d of document.querySelectorAll('link[rel="modulepreload"]'))c(d);new MutationObserver(d=>{for(const f of d)if(f.type==="childList")for(const m of f.addedNodes)m.tagName==="LINK"&&m.rel==="modulepreload"&&c(m)}).observe(document,{childList:!0,subtree:!0});function i(d){const f={};return d.integrity&&(f.integrity=d.integrity),d.referrerPolicy&&(f.referrerPolicy=d.referrerPolicy),d.crossOrigin==="use-credentials"?f.credentials="include":d.crossOrigin==="anonymous"?f.credentials="omit":f.credentials="same-origin",f}function c(d){if(d.ep)return;d.ep=!0;const f=i(d);fetch(d.href,f)}})();function nf(s){return s&&s.__esModule&&Object.prototype.hasOwnProperty.call(s,"default")?s.default:s}var no={exports:{}},Es={},ro={exports:{}},be={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var yd;function $m(){if(yd)return be;yd=1;var s=Symbol.for("react.element"),a=Symbol.for("react.portal"),i=Symbol.for("react.fragment"),c=Symbol.for("react.strict_mode"),d=Symbol.for("react.profiler"),f=Symbol.for("react.provider"),m=Symbol.for("react.context"),h=Symbol.for("react.forward_ref"),x=Symbol.for("react.suspense"),y=Symbol.for("react.memo"),j=Symbol.for("react.lazy"),_=Symbol.iterator;function O(b){return b===null||typeof b!="object"?null:(b=_&&b[_]||b["@@iterator"],typeof b=="function"?b:null)}var B={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},L=Object.assign,P={};function N(b,I,te){this.props=b,this.context=I,this.refs=P,this.updater=te||B}N.prototype.isReactComponent={},N.prototype.setState=function(b,I){if(typeof b!="object"&&typeof b!="function"&&b!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,b,I,"setState")},N.prototype.forceUpdate=function(b){this.updater.enqueueForceUpdate(this,b,"forceUpdate")};function A(){}A.prototype=N.prototype;function M(b,I,te){this.props=b,this.context=I,this.refs=P,this.updater=te||B}var K=M.prototype=new A;K.constructor=M,L(K,N.prototype),K.isPureReactComponent=!0;var Q=Array.isArray,me=Object.prototype.hasOwnProperty,fe={current:null},z={key:!0,ref:!0,__self:!0,__source:!0};function ae(b,I,te){var pe,ye={},Ne=null,we=null;if(I!=null)for(pe in I.ref!==void 0&&(we=I.ref),I.key!==void 0&&(Ne=""+I.key),I)me.call(I,pe)&&!z.hasOwnProperty(pe)&&(ye[pe]=I[pe]);var oe=arguments.length-2;if(oe===1)ye.children=te;else if(1>>1,I=U[b];if(0>>1;bd(ye,G))Ned(we,ye)?(U[b]=we,U[Ne]=G,b=Ne):(U[b]=ye,U[pe]=G,b=pe);else if(Ned(we,G))U[b]=we,U[Ne]=G,b=Ne;else break e}}return ee}function d(U,ee){var G=U.sortIndex-ee.sortIndex;return G!==0?G:U.id-ee.id}if(typeof performance=="object"&&typeof performance.now=="function"){var f=performance;s.unstable_now=function(){return f.now()}}else{var m=Date,h=m.now();s.unstable_now=function(){return m.now()-h}}var x=[],y=[],j=1,_=null,O=3,B=!1,L=!1,P=!1,N=typeof setTimeout=="function"?setTimeout:null,A=typeof clearTimeout=="function"?clearTimeout:null,M=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function K(U){for(var ee=i(y);ee!==null;){if(ee.callback===null)c(y);else if(ee.startTime<=U)c(y),ee.sortIndex=ee.expirationTime,a(x,ee);else break;ee=i(y)}}function Q(U){if(P=!1,K(U),!L)if(i(x)!==null)L=!0,ge(me);else{var ee=i(y);ee!==null&&D(Q,ee.startTime-U)}}function me(U,ee){L=!1,P&&(P=!1,A(ae),ae=-1),B=!0;var G=O;try{for(K(ee),_=i(x);_!==null&&(!(_.expirationTime>ee)||U&&!je());){var b=_.callback;if(typeof b=="function"){_.callback=null,O=_.priorityLevel;var I=b(_.expirationTime<=ee);ee=s.unstable_now(),typeof I=="function"?_.callback=I:_===i(x)&&c(x),K(ee)}else c(x);_=i(x)}if(_!==null)var te=!0;else{var pe=i(y);pe!==null&&D(Q,pe.startTime-ee),te=!1}return te}finally{_=null,O=G,B=!1}}var fe=!1,z=null,ae=-1,de=5,Y=-1;function je(){return!(s.unstable_now()-YU||125b?(U.sortIndex=G,a(y,U),i(x)===null&&U===i(y)&&(P?(A(ae),ae=-1):P=!0,D(Q,G-b))):(U.sortIndex=I,a(x,U),L||B||(L=!0,ge(me))),U},s.unstable_shouldYield=je,s.unstable_wrapCallback=function(U){var ee=O;return function(){var G=O;O=ee;try{return U.apply(this,arguments)}finally{O=G}}}})(ao)),ao}var Nd;function qm(){return Nd||(Nd=1,lo.exports=Wm()),lo.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Sd;function Km(){if(Sd)return gt;Sd=1;var s=Ro(),a=qm();function i(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),x=Object.prototype.hasOwnProperty,y=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,j={},_={};function O(e){return x.call(_,e)?!0:x.call(j,e)?!1:y.test(e)?_[e]=!0:(j[e]=!0,!1)}function B(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function L(e,t,n,r){if(t===null||typeof t>"u"||B(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function P(e,t,n,r,o,u,p){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=o,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=u,this.removeEmptyString=p}var N={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){N[e]=new P(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];N[t]=new P(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){N[e]=new P(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){N[e]=new P(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){N[e]=new P(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){N[e]=new P(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){N[e]=new P(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){N[e]=new P(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){N[e]=new P(e,5,!1,e.toLowerCase(),null,!1,!1)});var A=/[\-:]([a-z])/g;function M(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(A,M);N[t]=new P(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(A,M);N[t]=new P(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(A,M);N[t]=new P(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){N[e]=new P(e,1,!1,e.toLowerCase(),null,!1,!1)}),N.xlinkHref=new P("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){N[e]=new P(e,1,!1,e.toLowerCase(),null,!0,!0)});function K(e,t,n,r){var o=N.hasOwnProperty(t)?N[t]:null;(o!==null?o.type!==0:r||!(2g||o[p]!==u[g]){var k=` +`+o[p].replace(" at new "," at ");return e.displayName&&k.includes("")&&(k=k.replace("",e.displayName)),k}while(1<=p&&0<=g);break}}}finally{te=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?I(e):""}function ye(e){switch(e.tag){case 5:return I(e.type);case 16:return I("Lazy");case 13:return I("Suspense");case 19:return I("SuspenseList");case 0:case 2:case 15:return e=pe(e.type,!1),e;case 11:return e=pe(e.type.render,!1),e;case 1:return e=pe(e.type,!0),e;default:return""}}function Ne(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case z:return"Fragment";case fe:return"Portal";case de:return"Profiler";case ae:return"StrictMode";case ze:return"Suspense";case Ee:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case je:return(e.displayName||"Context")+".Consumer";case Y:return(e._context.displayName||"Context")+".Provider";case Te:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case q:return t=e.displayName||null,t!==null?t:Ne(e.type)||"Memo";case ge:t=e._payload,e=e._init;try{return Ne(e(t))}catch{}}return null}function we(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Ne(t);case 8:return t===ae?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function oe(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function F(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function ne(e){var t=F(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var o=n.get,u=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return o.call(this)},set:function(p){r=""+p,u.call(this,p)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(p){r=""+p},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function ke(e){e._valueTracker||(e._valueTracker=ne(e))}function Ce(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=F(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function De(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Le(e,t){var n=t.checked;return G({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Fr(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=oe(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Ur(e,t){t=t.checked,t!=null&&K(e,"checked",t,!1)}function wt(e,t){Ur(e,t);var n=oe(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Mr(e,t.type,n):t.hasOwnProperty("defaultValue")&&Mr(e,t.type,oe(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function rr(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Mr(e,t,n){(t!=="number"||De(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var On=Array.isArray;function an(e,t,n,r){if(e=e.options,t){t={};for(var o=0;o"+t.valueOf().toString()+"",t=sr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function zn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var An={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},va=["Webkit","ms","Moz","O"];Object.keys(An).forEach(function(e){va.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),An[t]=An[e]})});function Bs(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||An.hasOwnProperty(e)&&An[e]?(""+t).trim():t+"px"}function Vs(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,o=Bs(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,o):e[n]=o}}var wa=G({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Br(e,t){if(t){if(wa[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(i(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(i(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(i(61))}if(t.style!=null&&typeof t.style!="object")throw Error(i(62))}}function on(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Vr=null;function Hr(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Wr=null,Qt=null,w=null;function $(e){if(e=ps(e)){if(typeof Wr!="function")throw Error(i(280));var t=e.stateNode;t&&(t=fl(t),Wr(e.stateNode,e.type,t))}}function ue(e){Qt?w?w.push(e):w=[e]:Qt=e}function Se(){if(Qt){var e=Qt,t=w;if(w=Qt=null,$(e),t)for(e=0;e>>=0,e===0?32:31-(np(e)/rp|0)|0}var Qs=64,Js=4194304;function Jr(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Gs(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,o=e.suspendedLanes,u=e.pingedLanes,p=n&268435455;if(p!==0){var g=p&~o;g!==0?r=Jr(g):(u&=p,u!==0&&(r=Jr(u)))}else p=n&~o,p!==0?r=Jr(p):u!==0&&(r=Jr(u));if(r===0)return 0;if(t!==0&&t!==r&&(t&o)===0&&(o=r&-r,u=t&-t,o>=u||o===16&&(u&4194240)!==0))return t;if((r&4)!==0&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Gr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Lt(t),e[t]=n}function ip(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=ss),du=" ",fu=!1;function pu(e,t){switch(e){case"keyup":return Ap.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function mu(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var or=!1;function Fp(e,t){switch(e){case"compositionend":return mu(t);case"keypress":return t.which!==32?null:(fu=!0,du);case"textInput":return e=t.data,e===du&&fu?null:e;default:return null}}function Up(e,t){if(or)return e==="compositionend"||!Da&&pu(e,t)?(e=lu(),tl=Ra=mn=null,or=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=ku(n)}}function Nu(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Nu(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Su(){for(var e=window,t=De();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=De(e.document)}return t}function Ma(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Kp(e){var t=Su(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Nu(n.ownerDocument.documentElement,n)){if(r!==null&&Ma(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var o=n.textContent.length,u=Math.min(r.start,o);r=r.end===void 0?u:Math.min(r.end,o),!e.extend&&u>r&&(o=r,r=u,u=o),o=ju(n,u);var p=ju(n,r);o&&p&&(e.rangeCount!==1||e.anchorNode!==o.node||e.anchorOffset!==o.offset||e.focusNode!==p.node||e.focusOffset!==p.offset)&&(t=t.createRange(),t.setStart(o.node,o.offset),e.removeAllRanges(),u>r?(e.addRange(t),e.extend(p.node,p.offset)):(t.setEnd(p.node,p.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,ur=null,Ia=null,os=null,$a=!1;function bu(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;$a||ur==null||ur!==De(r)||(r=ur,"selectionStart"in r&&Ma(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),os&&is(os,r)||(os=r,r=ul(Ia,"onSelect"),0mr||(e.current=Za[mr],Za[mr]=null,mr--)}function Fe(e,t){mr++,Za[mr]=e.current,e.current=t}var yn={},st=gn(yn),ft=gn(!1),Un=yn;function hr(e,t){var n=e.type.contextTypes;if(!n)return yn;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var o={},u;for(u in n)o[u]=t[u];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=o),o}function pt(e){return e=e.childContextTypes,e!=null}function pl(){Me(ft),Me(st)}function Iu(e,t,n){if(st.current!==yn)throw Error(i(168));Fe(st,t),Fe(ft,n)}function $u(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var o in r)if(!(o in t))throw Error(i(108,we(e)||"Unknown",o));return G({},n,r)}function ml(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||yn,Un=st.current,Fe(st,e),Fe(ft,ft.current),!0}function Bu(e,t,n){var r=e.stateNode;if(!r)throw Error(i(169));n?(e=$u(e,t,Un),r.__reactInternalMemoizedMergedChildContext=e,Me(ft),Me(st),Fe(st,e)):Me(ft),Fe(ft,n)}var Gt=null,hl=!1,ei=!1;function Vu(e){Gt===null?Gt=[e]:Gt.push(e)}function lm(e){hl=!0,Vu(e)}function vn(){if(!ei&&Gt!==null){ei=!0;var e=0,t=Oe;try{var n=Gt;for(Oe=1;e>=p,o-=p,Yt=1<<32-Lt(t)+o|n<ve?(tt=xe,xe=null):tt=xe.sibling;var Re=V(E,xe,R[ve],J);if(Re===null){xe===null&&(xe=tt);break}e&&xe&&Re.alternate===null&&t(E,xe),S=u(Re,S,ve),he===null?ce=Re:he.sibling=Re,he=Re,xe=tt}if(ve===R.length)return n(E,xe),Ie&&In(E,ve),ce;if(xe===null){for(;veve?(tt=xe,xe=null):tt=xe.sibling;var En=V(E,xe,Re.value,J);if(En===null){xe===null&&(xe=tt);break}e&&xe&&En.alternate===null&&t(E,xe),S=u(En,S,ve),he===null?ce=En:he.sibling=En,he=En,xe=tt}if(Re.done)return n(E,xe),Ie&&In(E,ve),ce;if(xe===null){for(;!Re.done;ve++,Re=R.next())Re=W(E,Re.value,J),Re!==null&&(S=u(Re,S,ve),he===null?ce=Re:he.sibling=Re,he=Re);return Ie&&In(E,ve),ce}for(xe=r(E,xe);!Re.done;ve++,Re=R.next())Re=Z(xe,E,ve,Re.value,J),Re!==null&&(e&&Re.alternate!==null&&xe.delete(Re.key===null?ve:Re.key),S=u(Re,S,ve),he===null?ce=Re:he.sibling=Re,he=Re);return e&&xe.forEach(function(Mm){return t(E,Mm)}),Ie&&In(E,ve),ce}function Qe(E,S,R,J){if(typeof R=="object"&&R!==null&&R.type===z&&R.key===null&&(R=R.props.children),typeof R=="object"&&R!==null){switch(R.$$typeof){case me:e:{for(var ce=R.key,he=S;he!==null;){if(he.key===ce){if(ce=R.type,ce===z){if(he.tag===7){n(E,he.sibling),S=o(he,R.props.children),S.return=E,E=S;break e}}else if(he.elementType===ce||typeof ce=="object"&&ce!==null&&ce.$$typeof===ge&&Ju(ce)===he.type){n(E,he.sibling),S=o(he,R.props),S.ref=ms(E,he,R),S.return=E,E=S;break e}n(E,he);break}else t(E,he);he=he.sibling}R.type===z?(S=Qn(R.props.children,E.mode,J,R.key),S.return=E,E=S):(J=Vl(R.type,R.key,R.props,null,E.mode,J),J.ref=ms(E,S,R),J.return=E,E=J)}return p(E);case fe:e:{for(he=R.key;S!==null;){if(S.key===he)if(S.tag===4&&S.stateNode.containerInfo===R.containerInfo&&S.stateNode.implementation===R.implementation){n(E,S.sibling),S=o(S,R.children||[]),S.return=E,E=S;break e}else{n(E,S);break}else t(E,S);S=S.sibling}S=Yi(R,E.mode,J),S.return=E,E=S}return p(E);case ge:return he=R._init,Qe(E,S,he(R._payload),J)}if(On(R))return se(E,S,R,J);if(ee(R))return ie(E,S,R,J);vl(E,R)}return typeof R=="string"&&R!==""||typeof R=="number"?(R=""+R,S!==null&&S.tag===6?(n(E,S.sibling),S=o(S,R),S.return=E,E=S):(n(E,S),S=Gi(R,E.mode,J),S.return=E,E=S),p(E)):n(E,S)}return Qe}var vr=Gu(!0),Yu=Gu(!1),wl=gn(null),kl=null,wr=null,ai=null;function ii(){ai=wr=kl=null}function oi(e){var t=wl.current;Me(wl),e._currentValue=t}function ui(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function kr(e,t){kl=e,ai=wr=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(mt=!0),e.firstContext=null)}function _t(e){var t=e._currentValue;if(ai!==e)if(e={context:e,memoizedValue:t,next:null},wr===null){if(kl===null)throw Error(i(308));wr=e,kl.dependencies={lanes:0,firstContext:e}}else wr=wr.next=e;return t}var $n=null;function ci(e){$n===null?$n=[e]:$n.push(e)}function Xu(e,t,n,r){var o=t.interleaved;return o===null?(n.next=n,ci(t)):(n.next=o.next,o.next=n),t.interleaved=n,Zt(e,r)}function Zt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var wn=!1;function di(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Zu(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function en(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function kn(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,(Pe&2)!==0){var o=r.pending;return o===null?t.next=t:(t.next=o.next,o.next=t),r.pending=t,Zt(e,n)}return o=r.interleaved,o===null?(t.next=t,ci(r)):(t.next=o.next,o.next=t),r.interleaved=t,Zt(e,n)}function jl(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ba(e,n)}}function ec(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var o=null,u=null;if(n=n.firstBaseUpdate,n!==null){do{var p={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};u===null?o=u=p:u=u.next=p,n=n.next}while(n!==null);u===null?o=u=t:u=u.next=t}else o=u=t;n={baseState:r.baseState,firstBaseUpdate:o,lastBaseUpdate:u,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function Nl(e,t,n,r){var o=e.updateQueue;wn=!1;var u=o.firstBaseUpdate,p=o.lastBaseUpdate,g=o.shared.pending;if(g!==null){o.shared.pending=null;var k=g,T=k.next;k.next=null,p===null?u=T:p.next=T,p=k;var H=e.alternate;H!==null&&(H=H.updateQueue,g=H.lastBaseUpdate,g!==p&&(g===null?H.firstBaseUpdate=T:g.next=T,H.lastBaseUpdate=k))}if(u!==null){var W=o.baseState;p=0,H=T=k=null,g=u;do{var V=g.lane,Z=g.eventTime;if((r&V)===V){H!==null&&(H=H.next={eventTime:Z,lane:0,tag:g.tag,payload:g.payload,callback:g.callback,next:null});e:{var se=e,ie=g;switch(V=t,Z=n,ie.tag){case 1:if(se=ie.payload,typeof se=="function"){W=se.call(Z,W,V);break e}W=se;break e;case 3:se.flags=se.flags&-65537|128;case 0:if(se=ie.payload,V=typeof se=="function"?se.call(Z,W,V):se,V==null)break e;W=G({},W,V);break e;case 2:wn=!0}}g.callback!==null&&g.lane!==0&&(e.flags|=64,V=o.effects,V===null?o.effects=[g]:V.push(g))}else Z={eventTime:Z,lane:V,tag:g.tag,payload:g.payload,callback:g.callback,next:null},H===null?(T=H=Z,k=W):H=H.next=Z,p|=V;if(g=g.next,g===null){if(g=o.shared.pending,g===null)break;V=g,g=V.next,V.next=null,o.lastBaseUpdate=V,o.shared.pending=null}}while(!0);if(H===null&&(k=W),o.baseState=k,o.firstBaseUpdate=T,o.lastBaseUpdate=H,t=o.shared.interleaved,t!==null){o=t;do p|=o.lane,o=o.next;while(o!==t)}else u===null&&(o.shared.lanes=0);Hn|=p,e.lanes=p,e.memoizedState=W}}function tc(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=xi.transition;xi.transition={};try{e(!1),t()}finally{Oe=n,xi.transition=r}}function wc(){return Et().memoizedState}function um(e,t,n){var r=bn(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},kc(e))jc(t,n);else if(n=Xu(e,t,n,r),n!==null){var o=ct();Ut(n,e,r,o),Nc(n,t,r)}}function cm(e,t,n){var r=bn(e),o={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(kc(e))jc(t,o);else{var u=e.alternate;if(e.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var p=t.lastRenderedState,g=u(p,n);if(o.hasEagerState=!0,o.eagerState=g,Ot(g,p)){var k=t.interleaved;k===null?(o.next=o,ci(t)):(o.next=k.next,k.next=o),t.interleaved=o;return}}catch{}finally{}n=Xu(e,t,o,r),n!==null&&(o=ct(),Ut(n,e,r,o),Nc(n,t,r))}}function kc(e){var t=e.alternate;return e===Ve||t!==null&&t===Ve}function jc(e,t){ys=Cl=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Nc(e,t,n){if((n&4194240)!==0){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ba(e,n)}}var Pl={readContext:_t,useCallback:lt,useContext:lt,useEffect:lt,useImperativeHandle:lt,useInsertionEffect:lt,useLayoutEffect:lt,useMemo:lt,useReducer:lt,useRef:lt,useState:lt,useDebugValue:lt,useDeferredValue:lt,useTransition:lt,useMutableSource:lt,useSyncExternalStore:lt,useId:lt,unstable_isNewReconciler:!1},dm={readContext:_t,useCallback:function(e,t){return Wt().memoizedState=[e,t===void 0?null:t],e},useContext:_t,useEffect:fc,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,_l(4194308,4,hc.bind(null,t,e),n)},useLayoutEffect:function(e,t){return _l(4194308,4,e,t)},useInsertionEffect:function(e,t){return _l(4,2,e,t)},useMemo:function(e,t){var n=Wt();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Wt();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=um.bind(null,Ve,e),[r.memoizedState,e]},useRef:function(e){var t=Wt();return e={current:e},t.memoizedState=e},useState:cc,useDebugValue:Ni,useDeferredValue:function(e){return Wt().memoizedState=e},useTransition:function(){var e=cc(!1),t=e[0];return e=om.bind(null,e[1]),Wt().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=Ve,o=Wt();if(Ie){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),et===null)throw Error(i(349));(Vn&30)!==0||lc(r,t,n)}o.memoizedState=n;var u={value:n,getSnapshot:t};return o.queue=u,fc(ic.bind(null,r,u,e),[e]),r.flags|=2048,ks(9,ac.bind(null,r,u,n,t),void 0,null),n},useId:function(){var e=Wt(),t=et.identifierPrefix;if(Ie){var n=Xt,r=Yt;n=(r&~(1<<32-Lt(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=vs++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=p.createElement(n,{is:r.is}):(e=p.createElement(n),n==="select"&&(p=e,r.multiple?p.multiple=!0:r.size&&(p.size=r.size))):e=p.createElementNS(e,n),e[Vt]=t,e[fs]=r,Vc(e,t,!1,!1),t.stateNode=e;e:{switch(p=on(n,r),n){case"dialog":Ue("cancel",e),Ue("close",e),o=r;break;case"iframe":case"object":case"embed":Ue("load",e),o=r;break;case"video":case"audio":for(o=0;oCr&&(t.flags|=128,r=!0,js(u,!1),t.lanes=4194304)}else{if(!r)if(e=Sl(p),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),js(u,!0),u.tail===null&&u.tailMode==="hidden"&&!p.alternate&&!Ie)return at(t),null}else 2*Ke()-u.renderingStartTime>Cr&&n!==1073741824&&(t.flags|=128,r=!0,js(u,!1),t.lanes=4194304);u.isBackwards?(p.sibling=t.child,t.child=p):(n=u.last,n!==null?n.sibling=p:t.child=p,u.last=p)}return u.tail!==null?(t=u.tail,u.rendering=t,u.tail=t.sibling,u.renderingStartTime=Ke(),t.sibling=null,n=Be.current,Fe(Be,r?n&1|2:n&1),t):(at(t),null);case 22:case 23:return Ki(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&(t.mode&1)!==0?(St&1073741824)!==0&&(at(t),t.subtreeFlags&6&&(t.flags|=8192)):at(t),null;case 24:return null;case 25:return null}throw Error(i(156,t.tag))}function vm(e,t){switch(ni(t),t.tag){case 1:return pt(t.type)&&pl(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return jr(),Me(ft),Me(st),hi(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return pi(t),null;case 13:if(Me(Be),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));yr()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Me(Be),null;case 4:return jr(),null;case 10:return oi(t.type._context),null;case 22:case 23:return Ki(),null;case 24:return null;default:return null}}var Ol=!1,it=!1,wm=typeof WeakSet=="function"?WeakSet:Set,re=null;function Sr(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){We(e,t,r)}else n.current=null}function Ai(e,t,n){try{n()}catch(r){We(e,t,r)}}var qc=!1;function km(e,t){if(Ka=Zs,e=Su(),Ma(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var o=r.anchorOffset,u=r.focusNode;r=r.focusOffset;try{n.nodeType,u.nodeType}catch{n=null;break e}var p=0,g=-1,k=-1,T=0,H=0,W=e,V=null;t:for(;;){for(var Z;W!==n||o!==0&&W.nodeType!==3||(g=p+o),W!==u||r!==0&&W.nodeType!==3||(k=p+r),W.nodeType===3&&(p+=W.nodeValue.length),(Z=W.firstChild)!==null;)V=W,W=Z;for(;;){if(W===e)break t;if(V===n&&++T===o&&(g=p),V===u&&++H===r&&(k=p),(Z=W.nextSibling)!==null)break;W=V,V=W.parentNode}W=Z}n=g===-1||k===-1?null:{start:g,end:k}}else n=null}n=n||{start:0,end:0}}else n=null;for(Qa={focusedElem:e,selectionRange:n},Zs=!1,re=t;re!==null;)if(t=re,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,re=e;else for(;re!==null;){t=re;try{var se=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if(se!==null){var ie=se.memoizedProps,Qe=se.memoizedState,E=t.stateNode,S=E.getSnapshotBeforeUpdate(t.elementType===t.type?ie:At(t.type,ie),Qe);E.__reactInternalSnapshotBeforeUpdate=S}break;case 3:var R=t.stateNode.containerInfo;R.nodeType===1?R.textContent="":R.nodeType===9&&R.documentElement&&R.removeChild(R.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(i(163))}}catch(J){We(t,t.return,J)}if(e=t.sibling,e!==null){e.return=t.return,re=e;break}re=t.return}return se=qc,qc=!1,se}function Ns(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var o=r=r.next;do{if((o.tag&e)===e){var u=o.destroy;o.destroy=void 0,u!==void 0&&Ai(t,n,u)}o=o.next}while(o!==r)}}function zl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Di(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Kc(e){var t=e.alternate;t!==null&&(e.alternate=null,Kc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Vt],delete t[fs],delete t[Xa],delete t[rm],delete t[sm])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Qc(e){return e.tag===5||e.tag===3||e.tag===4}function Jc(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Qc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Fi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=dl));else if(r!==4&&(e=e.child,e!==null))for(Fi(e,t,n),e=e.sibling;e!==null;)Fi(e,t,n),e=e.sibling}function Ui(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Ui(e,t,n),e=e.sibling;e!==null;)Ui(e,t,n),e=e.sibling}var nt=null,Dt=!1;function jn(e,t,n){for(n=n.child;n!==null;)Gc(e,t,n),n=n.sibling}function Gc(e,t,n){if(Bt&&typeof Bt.onCommitFiberUnmount=="function")try{Bt.onCommitFiberUnmount(Ks,n)}catch{}switch(n.tag){case 5:it||Sr(n,t);case 6:var r=nt,o=Dt;nt=null,jn(e,t,n),nt=r,Dt=o,nt!==null&&(Dt?(e=nt,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):nt.removeChild(n.stateNode));break;case 18:nt!==null&&(Dt?(e=nt,n=n.stateNode,e.nodeType===8?Ya(e.parentNode,n):e.nodeType===1&&Ya(e,n),ts(e)):Ya(nt,n.stateNode));break;case 4:r=nt,o=Dt,nt=n.stateNode.containerInfo,Dt=!0,jn(e,t,n),nt=r,Dt=o;break;case 0:case 11:case 14:case 15:if(!it&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){o=r=r.next;do{var u=o,p=u.destroy;u=u.tag,p!==void 0&&((u&2)!==0||(u&4)!==0)&&Ai(n,t,p),o=o.next}while(o!==r)}jn(e,t,n);break;case 1:if(!it&&(Sr(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(g){We(n,t,g)}jn(e,t,n);break;case 21:jn(e,t,n);break;case 22:n.mode&1?(it=(r=it)||n.memoizedState!==null,jn(e,t,n),it=r):jn(e,t,n);break;default:jn(e,t,n)}}function Yc(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new wm),t.forEach(function(r){var o=Rm.bind(null,e,r);n.has(r)||(n.add(r),r.then(o,o))})}}function Ft(e,t){var n=t.deletions;if(n!==null)for(var r=0;ro&&(o=p),r&=~u}if(r=o,r=Ke()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Nm(r/1960))-r,10e?16:e,Sn===null)var r=!1;else{if(e=Sn,Sn=null,Ml=0,(Pe&6)!==0)throw Error(i(331));var o=Pe;for(Pe|=4,re=e.current;re!==null;){var u=re,p=u.child;if((re.flags&16)!==0){var g=u.deletions;if(g!==null){for(var k=0;kKe()-$i?qn(e,0):Ii|=n),xt(e,t)}function cd(e,t){t===0&&((e.mode&1)===0?t=1:(t=Js,Js<<=1,(Js&130023424)===0&&(Js=4194304)));var n=ct();e=Zt(e,t),e!==null&&(Gr(e,t,n),xt(e,n))}function Pm(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),cd(e,n)}function Rm(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,o=e.memoizedState;o!==null&&(n=o.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(i(314))}r!==null&&r.delete(t),cd(e,n)}var dd;dd=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||ft.current)mt=!0;else{if((e.lanes&n)===0&&(t.flags&128)===0)return mt=!1,gm(e,t,n);mt=(e.flags&131072)!==0}else mt=!1,Ie&&(t.flags&1048576)!==0&&Hu(t,gl,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Ll(e,t),e=t.pendingProps;var o=hr(t,st.current);kr(t,n),o=yi(null,t,r,e,o,n);var u=vi();return t.flags|=1,typeof o=="object"&&o!==null&&typeof o.render=="function"&&o.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,pt(r)?(u=!0,ml(t)):u=!1,t.memoizedState=o.state!==null&&o.state!==void 0?o.state:null,di(t),o.updater=Rl,t.stateNode=o,o._reactInternals=t,bi(t,r,e,n),t=Pi(null,t,r,!0,u,n)):(t.tag=0,Ie&&u&&ti(t),ut(null,t,o,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Ll(e,t),e=t.pendingProps,o=r._init,r=o(r._payload),t.type=r,o=t.tag=Lm(r),e=At(r,e),o){case 0:t=Ei(null,t,r,e,n);break e;case 1:t=Fc(null,t,r,e,n);break e;case 11:t=Lc(null,t,r,e,n);break e;case 14:t=Oc(null,t,r,At(r.type,e),n);break e}throw Error(i(306,r,""))}return t;case 0:return r=t.type,o=t.pendingProps,o=t.elementType===r?o:At(r,o),Ei(e,t,r,o,n);case 1:return r=t.type,o=t.pendingProps,o=t.elementType===r?o:At(r,o),Fc(e,t,r,o,n);case 3:e:{if(Uc(t),e===null)throw Error(i(387));r=t.pendingProps,u=t.memoizedState,o=u.element,Zu(e,t),Nl(t,r,null,n);var p=t.memoizedState;if(r=p.element,u.isDehydrated)if(u={element:r,isDehydrated:!1,cache:p.cache,pendingSuspenseBoundaries:p.pendingSuspenseBoundaries,transitions:p.transitions},t.updateQueue.baseState=u,t.memoizedState=u,t.flags&256){o=Nr(Error(i(423)),t),t=Mc(e,t,r,n,o);break e}else if(r!==o){o=Nr(Error(i(424)),t),t=Mc(e,t,r,n,o);break e}else for(Nt=xn(t.stateNode.containerInfo.firstChild),jt=t,Ie=!0,zt=null,n=Yu(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(yr(),r===o){t=tn(e,t,n);break e}ut(e,t,r,n)}t=t.child}return t;case 5:return nc(t),e===null&&si(t),r=t.type,o=t.pendingProps,u=e!==null?e.memoizedProps:null,p=o.children,Ja(r,o)?p=null:u!==null&&Ja(r,u)&&(t.flags|=32),Dc(e,t),ut(e,t,p,n),t.child;case 6:return e===null&&si(t),null;case 13:return Ic(e,t,n);case 4:return fi(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=vr(t,null,r,n):ut(e,t,r,n),t.child;case 11:return r=t.type,o=t.pendingProps,o=t.elementType===r?o:At(r,o),Lc(e,t,r,o,n);case 7:return ut(e,t,t.pendingProps,n),t.child;case 8:return ut(e,t,t.pendingProps.children,n),t.child;case 12:return ut(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,o=t.pendingProps,u=t.memoizedProps,p=o.value,Fe(wl,r._currentValue),r._currentValue=p,u!==null)if(Ot(u.value,p)){if(u.children===o.children&&!ft.current){t=tn(e,t,n);break e}}else for(u=t.child,u!==null&&(u.return=t);u!==null;){var g=u.dependencies;if(g!==null){p=u.child;for(var k=g.firstContext;k!==null;){if(k.context===r){if(u.tag===1){k=en(-1,n&-n),k.tag=2;var T=u.updateQueue;if(T!==null){T=T.shared;var H=T.pending;H===null?k.next=k:(k.next=H.next,H.next=k),T.pending=k}}u.lanes|=n,k=u.alternate,k!==null&&(k.lanes|=n),ui(u.return,n,t),g.lanes|=n;break}k=k.next}}else if(u.tag===10)p=u.type===t.type?null:u.child;else if(u.tag===18){if(p=u.return,p===null)throw Error(i(341));p.lanes|=n,g=p.alternate,g!==null&&(g.lanes|=n),ui(p,n,t),p=u.sibling}else p=u.child;if(p!==null)p.return=u;else for(p=u;p!==null;){if(p===t){p=null;break}if(u=p.sibling,u!==null){u.return=p.return,p=u;break}p=p.return}u=p}ut(e,t,o.children,n),t=t.child}return t;case 9:return o=t.type,r=t.pendingProps.children,kr(t,n),o=_t(o),r=r(o),t.flags|=1,ut(e,t,r,n),t.child;case 14:return r=t.type,o=At(r,t.pendingProps),o=At(r.type,o),Oc(e,t,r,o,n);case 15:return zc(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,o=t.pendingProps,o=t.elementType===r?o:At(r,o),Ll(e,t),t.tag=1,pt(r)?(e=!0,ml(t)):e=!1,kr(t,n),bc(t,r,o),bi(t,r,o,n),Pi(null,t,r,!0,e,n);case 19:return Bc(e,t,n);case 22:return Ac(e,t,n)}throw Error(i(156,t.tag))};function fd(e,t){return Wo(e,t)}function Tm(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Rt(e,t,n,r){return new Tm(e,t,n,r)}function Ji(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Lm(e){if(typeof e=="function")return Ji(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Te)return 11;if(e===q)return 14}return 2}function _n(e,t){var n=e.alternate;return n===null?(n=Rt(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Vl(e,t,n,r,o,u){var p=2;if(r=e,typeof e=="function")Ji(e)&&(p=1);else if(typeof e=="string")p=5;else e:switch(e){case z:return Qn(n.children,o,u,t);case ae:p=8,o|=8;break;case de:return e=Rt(12,n,t,o|2),e.elementType=de,e.lanes=u,e;case ze:return e=Rt(13,n,t,o),e.elementType=ze,e.lanes=u,e;case Ee:return e=Rt(19,n,t,o),e.elementType=Ee,e.lanes=u,e;case D:return Hl(n,o,u,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Y:p=10;break e;case je:p=9;break e;case Te:p=11;break e;case q:p=14;break e;case ge:p=16,r=null;break e}throw Error(i(130,e==null?e:typeof e,""))}return t=Rt(p,n,t,o),t.elementType=e,t.type=r,t.lanes=u,t}function Qn(e,t,n,r){return e=Rt(7,e,r,t),e.lanes=n,e}function Hl(e,t,n,r){return e=Rt(22,e,r,t),e.elementType=D,e.lanes=n,e.stateNode={isHidden:!1},e}function Gi(e,t,n){return e=Rt(6,e,null,t),e.lanes=n,e}function Yi(e,t,n){return t=Rt(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Om(e,t,n,r,o){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Sa(0),this.expirationTimes=Sa(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Sa(0),this.identifierPrefix=r,this.onRecoverableError=o,this.mutableSourceEagerHydrationData=null}function Xi(e,t,n,r,o,u,p,g,k){return e=new Om(e,t,n,g,k),t===1?(t=1,u===!0&&(t|=8)):t=0,u=Rt(3,null,null,t),e.current=u,u.stateNode=e,u.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},di(u),e}function zm(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(s)}catch(a){console.error(a)}}return s(),so.exports=Km(),so.exports}var Cd;function Qm(){if(Cd)return Yl;Cd=1;var s=sf();return Yl.createRoot=s.createRoot,Yl.hydrateRoot=s.hydrateRoot,Yl}var Jm=Qm();const Gm=nf(Jm);sf();/** + * @remix-run/router v1.23.2 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */function Rs(){return Rs=Object.assign?Object.assign.bind():function(s){for(var a=1;a"u")throw new Error(a)}function To(s,a){if(!s){typeof console<"u"&&console.warn(a);try{throw new Error(a)}catch{}}}function Xm(){return Math.random().toString(36).substr(2,8)}function Ed(s,a){return{usr:s.state,key:s.key,idx:a}}function go(s,a,i,c){return i===void 0&&(i=null),Rs({pathname:typeof s=="string"?s:s.pathname,search:"",hash:""},typeof a=="string"?Or(a):a,{state:i,key:a&&a.key||c||Xm()})}function sa(s){let{pathname:a="/",search:i="",hash:c=""}=s;return i&&i!=="?"&&(a+=i.charAt(0)==="?"?i:"?"+i),c&&c!=="#"&&(a+=c.charAt(0)==="#"?c:"#"+c),a}function Or(s){let a={};if(s){let i=s.indexOf("#");i>=0&&(a.hash=s.substr(i),s=s.substr(0,i));let c=s.indexOf("?");c>=0&&(a.search=s.substr(c),s=s.substr(0,c)),s&&(a.pathname=s)}return a}function Zm(s,a,i,c){c===void 0&&(c={});let{window:d=document.defaultView,v5Compat:f=!1}=c,m=d.history,h=Rn.Pop,x=null,y=j();y==null&&(y=0,m.replaceState(Rs({},m.state,{idx:y}),""));function j(){return(m.state||{idx:null}).idx}function _(){h=Rn.Pop;let N=j(),A=N==null?null:N-y;y=N,x&&x({action:h,location:P.location,delta:A})}function O(N,A){h=Rn.Push;let M=go(P.location,N,A);y=j()+1;let K=Ed(M,y),Q=P.createHref(M);try{m.pushState(K,"",Q)}catch(me){if(me instanceof DOMException&&me.name==="DataCloneError")throw me;d.location.assign(Q)}f&&x&&x({action:h,location:P.location,delta:1})}function B(N,A){h=Rn.Replace;let M=go(P.location,N,A);y=j();let K=Ed(M,y),Q=P.createHref(M);m.replaceState(K,"",Q),f&&x&&x({action:h,location:P.location,delta:0})}function L(N){let A=d.location.origin!=="null"?d.location.origin:d.location.href,M=typeof N=="string"?N:sa(N);return M=M.replace(/ $/,"%20"),He(A,"No window.location.(origin|href) available to create URL for href: "+M),new URL(M,A)}let P={get action(){return h},get location(){return s(d,m)},listen(N){if(x)throw new Error("A history only accepts one active listener");return d.addEventListener(_d,_),x=N,()=>{d.removeEventListener(_d,_),x=null}},createHref(N){return a(d,N)},createURL:L,encodeLocation(N){let A=L(N);return{pathname:A.pathname,search:A.search,hash:A.hash}},push:O,replace:B,go(N){return m.go(N)}};return P}var Pd;(function(s){s.data="data",s.deferred="deferred",s.redirect="redirect",s.error="error"})(Pd||(Pd={}));function e0(s,a,i){return i===void 0&&(i="/"),t0(s,a,i)}function t0(s,a,i,c){let d=typeof a=="string"?Or(a):a,f=Rr(d.pathname||"/",i);if(f==null)return null;let m=lf(s);n0(m);let h=null;for(let x=0;h==null&&x{let x={relativePath:h===void 0?f.path||"":h,caseSensitive:f.caseSensitive===!0,childrenIndex:m,route:f};x.relativePath.startsWith("/")&&(He(x.relativePath.startsWith(c),'Absolute route path "'+x.relativePath+'" nested under path '+('"'+c+'" is not valid. An absolute child route path ')+"must start with the combined path of all its parent routes."),x.relativePath=x.relativePath.slice(c.length));let y=Tn([c,x.relativePath]),j=i.concat(x);f.children&&f.children.length>0&&(He(f.index!==!0,"Index routes must not have child routes. Please remove "+('all child routes from route path "'+y+'".')),lf(f.children,a,j,y)),!(f.path==null&&!f.index)&&a.push({path:y,score:u0(y,f.index),routesMeta:j})};return s.forEach((f,m)=>{var h;if(f.path===""||!((h=f.path)!=null&&h.includes("?")))d(f,m);else for(let x of af(f.path))d(f,m,x)}),a}function af(s){let a=s.split("/");if(a.length===0)return[];let[i,...c]=a,d=i.endsWith("?"),f=i.replace(/\?$/,"");if(c.length===0)return d?[f,""]:[f];let m=af(c.join("/")),h=[];return h.push(...m.map(x=>x===""?f:[f,x].join("/"))),d&&h.push(...m),h.map(x=>s.startsWith("/")&&x===""?"/":x)}function n0(s){s.sort((a,i)=>a.score!==i.score?i.score-a.score:c0(a.routesMeta.map(c=>c.childrenIndex),i.routesMeta.map(c=>c.childrenIndex)))}const r0=/^:[\w-]+$/,s0=3,l0=2,a0=1,i0=10,o0=-2,Rd=s=>s==="*";function u0(s,a){let i=s.split("/"),c=i.length;return i.some(Rd)&&(c+=o0),a&&(c+=l0),i.filter(d=>!Rd(d)).reduce((d,f)=>d+(r0.test(f)?s0:f===""?a0:i0),c)}function c0(s,a){return s.length===a.length&&s.slice(0,-1).every((c,d)=>c===a[d])?s[s.length-1]-a[a.length-1]:0}function d0(s,a,i){let{routesMeta:c}=s,d={},f="/",m=[];for(let h=0;h{let{paramName:O,isOptional:B}=j;if(O==="*"){let P=h[_]||"";m=f.slice(0,f.length-P.length).replace(/(.)\/+$/,"$1")}const L=h[_];return B&&!L?y[O]=void 0:y[O]=(L||"").replace(/%2F/g,"/"),y},{}),pathname:f,pathnameBase:m,pattern:s}}function f0(s,a,i){a===void 0&&(a=!1),i===void 0&&(i=!0),To(s==="*"||!s.endsWith("*")||s.endsWith("/*"),'Route path "'+s+'" will be treated as if it were '+('"'+s.replace(/\*$/,"/*")+'" because the `*` character must ')+"always follow a `/` in the pattern. To get rid of this warning, "+('please change the route path to "'+s.replace(/\*$/,"/*")+'".'));let c=[],d="^"+s.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(m,h,x)=>(c.push({paramName:h,isOptional:x!=null}),x?"/?([^\\/]+)?":"/([^\\/]+)"));return s.endsWith("*")?(c.push({paramName:"*"}),d+=s==="*"||s==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):i?d+="\\/*$":s!==""&&s!=="/"&&(d+="(?:(?=\\/|$))"),[new RegExp(d,a?void 0:"i"),c]}function p0(s){try{return s.split("/").map(a=>decodeURIComponent(a).replace(/\//g,"%2F")).join("/")}catch(a){return To(!1,'The URL path "'+s+'" could not be decoded because it is is a malformed URL segment. This is probably due to a bad percent '+("encoding ("+a+").")),s}}function Rr(s,a){if(a==="/")return s;if(!s.toLowerCase().startsWith(a.toLowerCase()))return null;let i=a.endsWith("/")?a.length-1:a.length,c=s.charAt(i);return c&&c!=="/"?null:s.slice(i)||"/"}const m0=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,h0=s=>m0.test(s);function x0(s,a){a===void 0&&(a="/");let{pathname:i,search:c="",hash:d=""}=typeof s=="string"?Or(s):s,f;if(i)if(h0(i))f=i;else{if(i.includes("//")){let m=i;i=i.replace(/\/\/+/g,"/"),To(!1,"Pathnames cannot have embedded double slashes - normalizing "+(m+" -> "+i))}i.startsWith("/")?f=Td(i.substring(1),"/"):f=Td(i,a)}else f=a;return{pathname:f,search:v0(c),hash:w0(d)}}function Td(s,a){let i=a.replace(/\/+$/,"").split("/");return s.split("/").forEach(d=>{d===".."?i.length>1&&i.pop():d!=="."&&i.push(d)}),i.length>1?i.join("/"):"/"}function io(s,a,i,c){return"Cannot include a '"+s+"' character in a manually specified "+("`to."+a+"` field ["+JSON.stringify(c)+"]. Please separate it out to the ")+("`to."+i+"` field. Alternatively you may provide the full path as ")+'a string in and the router will parse it for you.'}function g0(s){return s.filter((a,i)=>i===0||a.route.path&&a.route.path.length>0)}function Lo(s,a){let i=g0(s);return a?i.map((c,d)=>d===i.length-1?c.pathname:c.pathnameBase):i.map(c=>c.pathnameBase)}function Oo(s,a,i,c){c===void 0&&(c=!1);let d;typeof s=="string"?d=Or(s):(d=Rs({},s),He(!d.pathname||!d.pathname.includes("?"),io("?","pathname","search",d)),He(!d.pathname||!d.pathname.includes("#"),io("#","pathname","hash",d)),He(!d.search||!d.search.includes("#"),io("#","search","hash",d)));let f=s===""||d.pathname==="",m=f?"/":d.pathname,h;if(m==null)h=i;else{let _=a.length-1;if(!c&&m.startsWith("..")){let O=m.split("/");for(;O[0]==="..";)O.shift(),_-=1;d.pathname=O.join("/")}h=_>=0?a[_]:"/"}let x=x0(d,h),y=m&&m!=="/"&&m.endsWith("/"),j=(f||m===".")&&i.endsWith("/");return!x.pathname.endsWith("/")&&(y||j)&&(x.pathname+="/"),x}const Tn=s=>s.join("/").replace(/\/\/+/g,"/"),y0=s=>s.replace(/\/+$/,"").replace(/^\/*/,"/"),v0=s=>!s||s==="?"?"":s.startsWith("?")?s:"?"+s,w0=s=>!s||s==="#"?"":s.startsWith("#")?s:"#"+s;function k0(s){return s!=null&&typeof s.status=="number"&&typeof s.statusText=="string"&&typeof s.internal=="boolean"&&"data"in s}const of=["post","put","patch","delete"];new Set(of);const j0=["get",...of];new Set(j0);/** + * React Router v6.30.3 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */function Ts(){return Ts=Object.assign?Object.assign.bind():function(s){for(var a=1;a{h.current=!0}),v.useCallback(function(y,j){if(j===void 0&&(j={}),!h.current)return;if(typeof y=="number"){c.go(y);return}let _=Oo(y,JSON.parse(m),f,j.relative==="path");s==null&&a!=="/"&&(_.pathname=_.pathname==="/"?a:Tn([a,_.pathname])),(j.replace?c.replace:c.push)(_,j.state,j)},[a,c,m,f,s])}const b0=v.createContext(null);function C0(s){let a=v.useContext(ln).outlet;return a&&v.createElement(b0.Provider,{value:s},a)}function da(s,a){let{relative:i}=a===void 0?{}:a,{future:c}=v.useContext(sn),{matches:d}=v.useContext(ln),{pathname:f}=Ar(),m=JSON.stringify(Lo(d,c.v7_relativeSplatPath));return v.useMemo(()=>Oo(s,JSON.parse(m),f,i==="path"),[s,m,f,i])}function _0(s,a){return E0(s,a)}function E0(s,a,i,c){zr()||He(!1);let{navigator:d}=v.useContext(sn),{matches:f}=v.useContext(ln),m=f[f.length-1],h=m?m.params:{};m&&m.pathname;let x=m?m.pathnameBase:"/";m&&m.route;let y=Ar(),j;if(a){var _;let N=typeof a=="string"?Or(a):a;x==="/"||(_=N.pathname)!=null&&_.startsWith(x)||He(!1),j=N}else j=y;let O=j.pathname||"/",B=O;if(x!=="/"){let N=x.replace(/^\//,"").split("/");B="/"+O.replace(/^\//,"").split("/").slice(N.length).join("/")}let L=e0(s,{pathname:B}),P=O0(L&&L.map(N=>Object.assign({},N,{params:Object.assign({},h,N.params),pathname:Tn([x,d.encodeLocation?d.encodeLocation(N.pathname).pathname:N.pathname]),pathnameBase:N.pathnameBase==="/"?x:Tn([x,d.encodeLocation?d.encodeLocation(N.pathnameBase).pathname:N.pathnameBase])})),f,i,c);return a&&P?v.createElement(ua.Provider,{value:{location:Ts({pathname:"/",search:"",hash:"",state:null,key:"default"},j),navigationType:Rn.Pop}},P):P}function P0(){let s=F0(),a=k0(s)?s.status+" "+s.statusText:s instanceof Error?s.message:JSON.stringify(s),i=s instanceof Error?s.stack:null,d={padding:"0.5rem",backgroundColor:"rgba(200,200,200, 0.5)"};return v.createElement(v.Fragment,null,v.createElement("h2",null,"Unexpected Application Error!"),v.createElement("h3",{style:{fontStyle:"italic"}},a),i?v.createElement("pre",{style:d},i):null,null)}const R0=v.createElement(P0,null);class T0 extends v.Component{constructor(a){super(a),this.state={location:a.location,revalidation:a.revalidation,error:a.error}}static getDerivedStateFromError(a){return{error:a}}static getDerivedStateFromProps(a,i){return i.location!==a.location||i.revalidation!=="idle"&&a.revalidation==="idle"?{error:a.error,location:a.location,revalidation:a.revalidation}:{error:a.error!==void 0?a.error:i.error,location:i.location,revalidation:a.revalidation||i.revalidation}}componentDidCatch(a,i){console.error("React Router caught the following error during render",a,i)}render(){return this.state.error!==void 0?v.createElement(ln.Provider,{value:this.props.routeContext},v.createElement(cf.Provider,{value:this.state.error,children:this.props.component})):this.props.children}}function L0(s){let{routeContext:a,match:i,children:c}=s,d=v.useContext(oa);return d&&d.static&&d.staticContext&&(i.route.errorElement||i.route.ErrorBoundary)&&(d.staticContext._deepestRenderedBoundaryId=i.route.id),v.createElement(ln.Provider,{value:a},c)}function O0(s,a,i,c){var d;if(a===void 0&&(a=[]),i===void 0&&(i=null),c===void 0&&(c=null),s==null){var f;if(!i)return null;if(i.errors)s=i.matches;else if((f=c)!=null&&f.v7_partialHydration&&a.length===0&&!i.initialized&&i.matches.length>0)s=i.matches;else return null}let m=s,h=(d=i)==null?void 0:d.errors;if(h!=null){let j=m.findIndex(_=>_.route.id&&(h==null?void 0:h[_.route.id])!==void 0);j>=0||He(!1),m=m.slice(0,Math.min(m.length,j+1))}let x=!1,y=-1;if(i&&c&&c.v7_partialHydration)for(let j=0;j=0?m=m.slice(0,y+1):m=[m[0]];break}}}return m.reduceRight((j,_,O)=>{let B,L=!1,P=null,N=null;i&&(B=h&&_.route.id?h[_.route.id]:void 0,P=_.route.errorElement||R0,x&&(y<0&&O===0?(M0("route-fallback"),L=!0,N=null):y===O&&(L=!0,N=_.route.hydrateFallbackElement||null)));let A=a.concat(m.slice(0,O+1)),M=()=>{let K;return B?K=P:L?K=N:_.route.Component?K=v.createElement(_.route.Component,null):_.route.element?K=_.route.element:K=j,v.createElement(L0,{match:_,routeContext:{outlet:j,matches:A,isDataRoute:i!=null},children:K})};return i&&(_.route.ErrorBoundary||_.route.errorElement||O===0)?v.createElement(T0,{location:i.location,revalidation:i.revalidation,component:P,error:B,children:M(),routeContext:{outlet:null,matches:A,isDataRoute:!0}}):M()},null)}var ff=(function(s){return s.UseBlocker="useBlocker",s.UseRevalidator="useRevalidator",s.UseNavigateStable="useNavigate",s})(ff||{}),pf=(function(s){return s.UseBlocker="useBlocker",s.UseLoaderData="useLoaderData",s.UseActionData="useActionData",s.UseRouteError="useRouteError",s.UseNavigation="useNavigation",s.UseRouteLoaderData="useRouteLoaderData",s.UseMatches="useMatches",s.UseRevalidator="useRevalidator",s.UseNavigateStable="useNavigate",s.UseRouteId="useRouteId",s})(pf||{});function z0(s){let a=v.useContext(oa);return a||He(!1),a}function A0(s){let a=v.useContext(uf);return a||He(!1),a}function D0(s){let a=v.useContext(ln);return a||He(!1),a}function mf(s){let a=D0(),i=a.matches[a.matches.length-1];return i.route.id||He(!1),i.route.id}function F0(){var s;let a=v.useContext(cf),i=A0(),c=mf();return a!==void 0?a:(s=i.errors)==null?void 0:s[c]}function U0(){let{router:s}=z0(ff.UseNavigateStable),a=mf(pf.UseNavigateStable),i=v.useRef(!1);return df(()=>{i.current=!0}),v.useCallback(function(d,f){f===void 0&&(f={}),i.current&&(typeof d=="number"?s.navigate(d):s.navigate(d,Ts({fromRouteId:a},f)))},[s,a])}const Ld={};function M0(s,a,i){Ld[s]||(Ld[s]=!0)}function I0(s,a){s==null||s.v7_startTransition,s==null||s.v7_relativeSplatPath}function vo(s){let{to:a,replace:i,state:c,relative:d}=s;zr()||He(!1);let{future:f,static:m}=v.useContext(sn),{matches:h}=v.useContext(ln),{pathname:x}=Ar(),y=ca(),j=Oo(a,Lo(h,f.v7_relativeSplatPath),x,d==="path"),_=JSON.stringify(j);return v.useEffect(()=>y(JSON.parse(_),{replace:i,state:c,relative:d}),[y,_,d,i,c]),null}function $0(s){return C0(s.context)}function yt(s){He(!1)}function B0(s){let{basename:a="/",children:i=null,location:c,navigationType:d=Rn.Pop,navigator:f,static:m=!1,future:h}=s;zr()&&He(!1);let x=a.replace(/^\/*/,"/"),y=v.useMemo(()=>({basename:x,navigator:f,static:m,future:Ts({v7_relativeSplatPath:!1},h)}),[x,h,f,m]);typeof c=="string"&&(c=Or(c));let{pathname:j="/",search:_="",hash:O="",state:B=null,key:L="default"}=c,P=v.useMemo(()=>{let N=Rr(j,x);return N==null?null:{location:{pathname:N,search:_,hash:O,state:B,key:L},navigationType:d}},[x,j,_,O,B,L,d]);return P==null?null:v.createElement(sn.Provider,{value:y},v.createElement(ua.Provider,{children:i,value:P}))}function V0(s){let{children:a,location:i}=s;return _0(wo(a),i)}new Promise(()=>{});function wo(s,a){a===void 0&&(a=[]);let i=[];return v.Children.forEach(s,(c,d)=>{if(!v.isValidElement(c))return;let f=[...a,d];if(c.type===v.Fragment){i.push.apply(i,wo(c.props.children,f));return}c.type!==yt&&He(!1),!c.props.index||!c.props.children||He(!1);let m={id:c.props.id||f.join("-"),caseSensitive:c.props.caseSensitive,element:c.props.element,Component:c.props.Component,index:c.props.index,path:c.props.path,loader:c.props.loader,action:c.props.action,errorElement:c.props.errorElement,ErrorBoundary:c.props.ErrorBoundary,hasErrorBoundary:c.props.ErrorBoundary!=null||c.props.errorElement!=null,shouldRevalidate:c.props.shouldRevalidate,handle:c.props.handle,lazy:c.props.lazy};c.props.children&&(m.children=wo(c.props.children,f)),i.push(m)}),i}/** + * React Router DOM v6.30.3 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */function la(){return la=Object.assign?Object.assign.bind():function(s){for(var a=1;a=0)&&(i[d]=s[d]);return i}function H0(s){return!!(s.metaKey||s.altKey||s.ctrlKey||s.shiftKey)}function W0(s,a){return s.button===0&&(!a||a==="_self")&&!H0(s)}const q0=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","viewTransition"],K0=["aria-current","caseSensitive","className","end","style","to","viewTransition","children"],Q0="6";try{window.__reactRouterVersion=Q0}catch{}const J0=v.createContext({isTransitioning:!1}),G0="startTransition",Od=Hm[G0];function Y0(s){let{basename:a,children:i,future:c,window:d}=s,f=v.useRef();f.current==null&&(f.current=Ym({window:d,v5Compat:!0}));let m=f.current,[h,x]=v.useState({action:m.action,location:m.location}),{v7_startTransition:y}=c||{},j=v.useCallback(_=>{y&&Od?Od(()=>x(_)):x(_)},[x,y]);return v.useLayoutEffect(()=>m.listen(j),[m,j]),v.useEffect(()=>I0(c),[c]),v.createElement(B0,{basename:a,children:i,location:h.location,navigationType:h.action,navigator:m,future:c})}const X0=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u",Z0=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,eh=v.forwardRef(function(a,i){let{onClick:c,relative:d,reloadDocument:f,replace:m,state:h,target:x,to:y,preventScrollReset:j,viewTransition:_}=a,O=hf(a,q0),{basename:B}=v.useContext(sn),L,P=!1;if(typeof y=="string"&&Z0.test(y)&&(L=y,X0))try{let K=new URL(window.location.href),Q=y.startsWith("//")?new URL(K.protocol+y):new URL(y),me=Rr(Q.pathname,B);Q.origin===K.origin&&me!=null?y=me+Q.search+Q.hash:P=!0}catch{}let N=N0(y,{relative:d}),A=rh(y,{replace:m,state:h,target:x,preventScrollReset:j,relative:d,viewTransition:_});function M(K){c&&c(K),K.defaultPrevented||A(K)}return v.createElement("a",la({},O,{href:L||N,onClick:P||f?c:M,ref:i,target:x}))}),th=v.forwardRef(function(a,i){let{"aria-current":c="page",caseSensitive:d=!1,className:f="",end:m=!1,style:h,to:x,viewTransition:y,children:j}=a,_=hf(a,K0),O=da(x,{relative:_.relative}),B=Ar(),L=v.useContext(uf),{navigator:P,basename:N}=v.useContext(sn),A=L!=null&&sh(O)&&y===!0,M=P.encodeLocation?P.encodeLocation(O).pathname:O.pathname,K=B.pathname,Q=L&&L.navigation&&L.navigation.location?L.navigation.location.pathname:null;d||(K=K.toLowerCase(),Q=Q?Q.toLowerCase():null,M=M.toLowerCase()),Q&&N&&(Q=Rr(Q,N)||Q);const me=M!=="/"&&M.endsWith("/")?M.length-1:M.length;let fe=K===M||!m&&K.startsWith(M)&&K.charAt(me)==="/",z=Q!=null&&(Q===M||!m&&Q.startsWith(M)&&Q.charAt(M.length)==="/"),ae={isActive:fe,isPending:z,isTransitioning:A},de=fe?c:void 0,Y;typeof f=="function"?Y=f(ae):Y=[f,fe?"active":null,z?"pending":null,A?"transitioning":null].filter(Boolean).join(" ");let je=typeof h=="function"?h(ae):h;return v.createElement(eh,la({},_,{"aria-current":de,className:Y,ref:i,style:je,to:x,viewTransition:y}),typeof j=="function"?j(ae):j)});var ko;(function(s){s.UseScrollRestoration="useScrollRestoration",s.UseSubmit="useSubmit",s.UseSubmitFetcher="useSubmitFetcher",s.UseFetcher="useFetcher",s.useViewTransitionState="useViewTransitionState"})(ko||(ko={}));var zd;(function(s){s.UseFetcher="useFetcher",s.UseFetchers="useFetchers",s.UseScrollRestoration="useScrollRestoration"})(zd||(zd={}));function nh(s){let a=v.useContext(oa);return a||He(!1),a}function rh(s,a){let{target:i,replace:c,state:d,preventScrollReset:f,relative:m,viewTransition:h}=a===void 0?{}:a,x=ca(),y=Ar(),j=da(s,{relative:m});return v.useCallback(_=>{if(W0(_,i)){_.preventDefault();let O=c!==void 0?c:sa(y)===sa(j);x(s,{replace:O,state:d,preventScrollReset:f,relative:m,viewTransition:h})}},[y,x,j,c,d,i,s,f,m,h])}function sh(s,a){a===void 0&&(a={});let i=v.useContext(J0);i==null&&He(!1);let{basename:c}=nh(ko.useViewTransitionState),d=da(s,{relative:a.relative});if(!i.isTransitioning)return!1;let f=Rr(i.currentLocation.pathname,c)||i.currentLocation.pathname,m=Rr(i.nextLocation.pathname,c)||i.nextLocation.pathname;return yo(d.pathname,m)!=null||yo(d.pathname,f)!=null}/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const lh=s=>s.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),xf=(...s)=>s.filter((a,i,c)=>!!a&&a.trim()!==""&&c.indexOf(a)===i).join(" ").trim();/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */var ah={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const ih=v.forwardRef(({color:s="currentColor",size:a=24,strokeWidth:i=2,absoluteStrokeWidth:c,className:d="",children:f,iconNode:m,...h},x)=>v.createElement("svg",{ref:x,...ah,width:a,height:a,stroke:s,strokeWidth:c?Number(i)*24/Number(a):i,className:xf("lucide",d),...h},[...m.map(([y,j])=>v.createElement(y,j)),...Array.isArray(f)?f:[f]]));/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const _e=(s,a)=>{const i=v.forwardRef(({className:c,...d},f)=>v.createElement(ih,{ref:f,iconNode:a,className:xf(`lucide-${lh(s)}`,c),...d}));return i.displayName=`${s}`,i};/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const oh=_e("Activity",[["path",{d:"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2",key:"169zse"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const gf=_e("Bell",[["path",{d:"M10.268 21a2 2 0 0 0 3.464 0",key:"vwvbt9"}],["path",{d:"M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326",key:"11g9vi"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const oo=_e("Check",[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const jo=_e("ChevronDown",[["path",{d:"m6 9 6 6 6-6",key:"qrunsl"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const No=_e("ChevronRight",[["path",{d:"m9 18 6-6-6-6",key:"mthhwq"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const uh=_e("ChevronUp",[["path",{d:"m18 15-6-6-6 6",key:"153udz"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ad=_e("CircleCheckBig",[["path",{d:"M21.801 10A10 10 0 1 1 17 3.335",key:"yps3ct"}],["path",{d:"m9 11 3 3L22 4",key:"1pflzl"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const yf=_e("Clapperboard",[["path",{d:"M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z",key:"1tn4o7"}],["path",{d:"m6.2 5.3 3.1 3.9",key:"iuk76l"}],["path",{d:"m12.4 3.4 3.1 4",key:"6hsd6n"}],["path",{d:"M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z",key:"ltgou9"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const So=_e("Clock",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["polyline",{points:"12 6 12 12 16 14",key:"68esgv"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Dd=_e("Cpu",[["rect",{width:"16",height:"16",x:"4",y:"4",rx:"2",key:"14l7u7"}],["rect",{width:"6",height:"6",x:"9",y:"9",rx:"1",key:"5aljv4"}],["path",{d:"M15 2v2",key:"13l42r"}],["path",{d:"M15 20v2",key:"15mkzm"}],["path",{d:"M2 15h2",key:"1gxd5l"}],["path",{d:"M2 9h2",key:"1bbxkp"}],["path",{d:"M20 15h2",key:"19e6y8"}],["path",{d:"M20 9h2",key:"19tzq7"}],["path",{d:"M9 2v2",key:"165o2o"}],["path",{d:"M9 20v2",key:"i2bqo8"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const ch=_e("Database",[["ellipse",{cx:"12",cy:"5",rx:"9",ry:"3",key:"msslwz"}],["path",{d:"M3 5V19A9 3 0 0 0 21 19V5",key:"1wlel7"}],["path",{d:"M3 12A9 3 0 0 0 21 12",key:"mv7ke4"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Gn=_e("Film",[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}],["path",{d:"M7 3v18",key:"bbkbws"}],["path",{d:"M3 7.5h4",key:"zfgn84"}],["path",{d:"M3 12h18",key:"1i2n21"}],["path",{d:"M3 16.5h4",key:"1230mu"}],["path",{d:"M17 3v18",key:"in4fa5"}],["path",{d:"M17 7.5h4",key:"myr1c1"}],["path",{d:"M17 16.5h4",key:"go4c1d"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const vf=_e("HeartPulse",[["path",{d:"M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z",key:"c3ymky"}],["path",{d:"M3.22 12H9.5l.5-1 2 4.5 2-7 1.5 3.5h5.27",key:"1uw2ng"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const dh=_e("Image",[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2",key:"1m3agn"}],["circle",{cx:"9",cy:"9",r:"2",key:"af1f0g"}],["path",{d:"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21",key:"1xmnt7"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const fh=_e("Info",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M12 16v-4",key:"1dtifu"}],["path",{d:"M12 8h.01",key:"e9boi3"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const ph=_e("LayoutDashboard",[["rect",{width:"7",height:"9",x:"3",y:"3",rx:"1",key:"10lvy0"}],["rect",{width:"7",height:"5",x:"14",y:"3",rx:"1",key:"16une8"}],["rect",{width:"7",height:"9",x:"14",y:"12",rx:"1",key:"1hutg5"}],["rect",{width:"7",height:"5",x:"3",y:"16",rx:"1",key:"ldoo1y"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const wf=_e("List",[["path",{d:"M3 12h.01",key:"nlz23k"}],["path",{d:"M3 18h.01",key:"1tta3j"}],["path",{d:"M3 6h.01",key:"1rqtza"}],["path",{d:"M8 12h13",key:"1za7za"}],["path",{d:"M8 18h13",key:"1lx6n3"}],["path",{d:"M8 6h13",key:"ik3vkj"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Xl=_e("Loader",[["path",{d:"M12 2v4",key:"3427ic"}],["path",{d:"m16.2 7.8 2.9-2.9",key:"r700ao"}],["path",{d:"M18 12h4",key:"wj9ykh"}],["path",{d:"m16.2 16.2 2.9 2.9",key:"1bxg5t"}],["path",{d:"M12 18v4",key:"jadmvz"}],["path",{d:"m4.9 19.1 2.9-2.9",key:"bwix9q"}],["path",{d:"M2 12h4",key:"j09sii"}],["path",{d:"m4.9 4.9 2.9 2.9",key:"giyufr"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const mh=_e("LogOut",[["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4",key:"1uf3rs"}],["polyline",{points:"16 17 21 12 16 7",key:"1gabdz"}],["line",{x1:"21",x2:"9",y1:"12",y2:"12",key:"1uyos4"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const kf=_e("Pen",[["path",{d:"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z",key:"1a8usu"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const hh=_e("Pencil",[["path",{d:"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z",key:"1a8usu"}],["path",{d:"m15 5 4 4",key:"1mk7zo"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const tr=_e("Plus",[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"M12 5v14",key:"s699le"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ge=_e("RefreshCw",[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const xh=_e("ScrollText",[["path",{d:"M15 12h-5",key:"r7krc0"}],["path",{d:"M15 8h-5",key:"1khuty"}],["path",{d:"M19 17V5a2 2 0 0 0-2-2H4",key:"zz82l3"}],["path",{d:"M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3",key:"1ph1d7"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Kt=_e("Search",[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["path",{d:"m21 21-4.3-4.3",key:"1qie3q"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const zo=_e("Server",[["rect",{width:"20",height:"8",x:"2",y:"2",rx:"2",ry:"2",key:"ngkwjq"}],["rect",{width:"20",height:"8",x:"2",y:"14",rx:"2",ry:"2",key:"iecqi9"}],["line",{x1:"6",x2:"6.01",y1:"6",y2:"6",key:"16zg32"}],["line",{x1:"6",x2:"6.01",y1:"18",y2:"18",key:"nzw8ys"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const gh=_e("Settings",[["path",{d:"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z",key:"1qme2f"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const yh=_e("ShieldCheck",[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z",key:"oel41y"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Pn=_e("Star",[["path",{d:"M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z",key:"r04s7s"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const rn=_e("Trash2",[["path",{d:"M3 6h18",key:"d0wm0j"}],["path",{d:"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6",key:"4alrt4"}],["path",{d:"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2",key:"v07s0e"}],["line",{x1:"10",x2:"10",y1:"11",y2:"17",key:"1uufr5"}],["line",{x1:"14",x2:"14",y1:"11",y2:"17",key:"xtxkd"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Zn=_e("TriangleAlert",[["path",{d:"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3",key:"wmoenq"}],["path",{d:"M12 9v4",key:"juzpu7"}],["path",{d:"M12 17h.01",key:"p32p05"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Tr=_e("Tv",[["rect",{width:"20",height:"15",x:"2",y:"7",rx:"2",ry:"2",key:"10ag99"}],["polyline",{points:"17 2 12 7 7 2",key:"11pgbg"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const vh=_e("Upload",[["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4",key:"ih7n3h"}],["polyline",{points:"17 8 12 3 7 8",key:"t8dd8p"}],["line",{x1:"12",x2:"12",y1:"3",y2:"15",key:"widbto"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const aa=_e("Users",[["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2",key:"1yyitq"}],["circle",{cx:"9",cy:"7",r:"4",key:"nufk8"}],["path",{d:"M22 21v-2a4 4 0 0 0-3-3.87",key:"kshegd"}],["path",{d:"M16 3.13a4 4 0 0 1 0 7.75",key:"1da9ce"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const fa=_e("Wifi",[["path",{d:"M12 20h.01",key:"zekei9"}],["path",{d:"M2 8.82a15 15 0 0 1 20 0",key:"dnpr2z"}],["path",{d:"M5 12.859a10 10 0 0 1 14 0",key:"1x1e6c"}],["path",{d:"M8.5 16.429a5 5 0 0 1 7 0",key:"1bycff"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ln=_e("X",[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]]);function jf(s,a){return function(){return s.apply(a,arguments)}}const{toString:wh}=Object.prototype,{getPrototypeOf:pa}=Object,{iterator:ma,toStringTag:Nf}=Symbol,ha=(s=>a=>{const i=wh.call(a);return s[i]||(s[i]=i.slice(8,-1).toLowerCase())})(Object.create(null)),It=s=>(s=s.toLowerCase(),a=>ha(a)===s),xa=s=>a=>typeof a===s,{isArray:Dr}=Array,Lr=xa("undefined");function Ls(s){return s!==null&&!Lr(s)&&s.constructor!==null&&!Lr(s.constructor)&&vt(s.constructor.isBuffer)&&s.constructor.isBuffer(s)}const Sf=It("ArrayBuffer");function kh(s){let a;return typeof ArrayBuffer<"u"&&ArrayBuffer.isView?a=ArrayBuffer.isView(s):a=s&&s.buffer&&Sf(s.buffer),a}const jh=xa("string"),vt=xa("function"),bf=xa("number"),Os=s=>s!==null&&typeof s=="object",Nh=s=>s===!0||s===!1,ta=s=>{if(ha(s)!=="object")return!1;const a=pa(s);return(a===null||a===Object.prototype||Object.getPrototypeOf(a)===null)&&!(Nf in s)&&!(ma in s)},Sh=s=>{if(!Os(s)||Ls(s))return!1;try{return Object.keys(s).length===0&&Object.getPrototypeOf(s)===Object.prototype}catch{return!1}},bh=It("Date"),Ch=It("File"),_h=s=>!!(s&&typeof s.uri<"u"),Eh=s=>s&&typeof s.getParts<"u",Ph=It("Blob"),Rh=It("FileList"),Th=s=>Os(s)&&vt(s.pipe);function Lh(){return typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{}}const Fd=Lh(),Ud=typeof Fd.FormData<"u"?Fd.FormData:void 0,Oh=s=>{if(!s)return!1;if(Ud&&s instanceof Ud)return!0;const a=pa(s);if(!a||a===Object.prototype||!vt(s.append))return!1;const i=ha(s);return i==="formdata"||i==="object"&&vt(s.toString)&&s.toString()==="[object FormData]"},zh=It("URLSearchParams"),[Ah,Dh,Fh,Uh]=["ReadableStream","Request","Response","Headers"].map(It),Mh=s=>s.trim?s.trim():s.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"");function zs(s,a,{allOwnKeys:i=!1}={}){if(s===null||typeof s>"u")return;let c,d;if(typeof s!="object"&&(s=[s]),Dr(s))for(c=0,d=s.length;c0;)if(d=i[c],a===d.toLowerCase())return d;return null}const Yn=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:global,_f=s=>!Lr(s)&&s!==Yn;function bo(...s){const{caseless:a,skipUndefined:i}=_f(this)&&this||{},c={},d=(f,m)=>{if(m==="__proto__"||m==="constructor"||m==="prototype")return;const h=a&&Cf(c,m)||m,x=Co(c,h)?c[h]:void 0;ta(x)&&ta(f)?c[h]=bo(x,f):ta(f)?c[h]=bo({},f):Dr(f)?c[h]=f.slice():(!i||!Lr(f))&&(c[h]=f)};for(let f=0,m=s.length;f(zs(a,(d,f)=>{i&&vt(d)?Object.defineProperty(s,f,{__proto__:null,value:jf(d,i),writable:!0,enumerable:!0,configurable:!0}):Object.defineProperty(s,f,{__proto__:null,value:d,writable:!0,enumerable:!0,configurable:!0})},{allOwnKeys:c}),s),$h=s=>(s.charCodeAt(0)===65279&&(s=s.slice(1)),s),Bh=(s,a,i,c)=>{s.prototype=Object.create(a.prototype,c),Object.defineProperty(s.prototype,"constructor",{__proto__:null,value:s,writable:!0,enumerable:!1,configurable:!0}),Object.defineProperty(s,"super",{__proto__:null,value:a.prototype}),i&&Object.assign(s.prototype,i)},Vh=(s,a,i,c)=>{let d,f,m;const h={};if(a=a||{},s==null)return a;do{for(d=Object.getOwnPropertyNames(s),f=d.length;f-- >0;)m=d[f],(!c||c(m,s,a))&&!h[m]&&(a[m]=s[m],h[m]=!0);s=i!==!1&&pa(s)}while(s&&(!i||i(s,a))&&s!==Object.prototype);return a},Hh=(s,a,i)=>{s=String(s),(i===void 0||i>s.length)&&(i=s.length),i-=a.length;const c=s.indexOf(a,i);return c!==-1&&c===i},Wh=s=>{if(!s)return null;if(Dr(s))return s;let a=s.length;if(!bf(a))return null;const i=new Array(a);for(;a-- >0;)i[a]=s[a];return i},qh=(s=>a=>s&&a instanceof s)(typeof Uint8Array<"u"&&pa(Uint8Array)),Kh=(s,a)=>{const c=(s&&s[ma]).call(s);let d;for(;(d=c.next())&&!d.done;){const f=d.value;a.call(s,f[0],f[1])}},Qh=(s,a)=>{let i;const c=[];for(;(i=s.exec(a))!==null;)c.push(i);return c},Jh=It("HTMLFormElement"),Gh=s=>s.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(i,c,d){return c.toUpperCase()+d}),Co=(({hasOwnProperty:s})=>(a,i)=>s.call(a,i))(Object.prototype),Yh=It("RegExp"),Ef=(s,a)=>{const i=Object.getOwnPropertyDescriptors(s),c={};zs(i,(d,f)=>{let m;(m=a(d,f,s))!==!1&&(c[f]=m||d)}),Object.defineProperties(s,c)},Xh=s=>{Ef(s,(a,i)=>{if(vt(s)&&["arguments","caller","callee"].includes(i))return!1;const c=s[i];if(vt(c)){if(a.enumerable=!1,"writable"in a){a.writable=!1;return}a.set||(a.set=()=>{throw Error("Can not rewrite read-only method '"+i+"'")})}})},Zh=(s,a)=>{const i={},c=d=>{d.forEach(f=>{i[f]=!0})};return Dr(s)?c(s):c(String(s).split(a)),i},ex=()=>{},tx=(s,a)=>s!=null&&Number.isFinite(s=+s)?s:a;function nx(s){return!!(s&&vt(s.append)&&s[Nf]==="FormData"&&s[ma])}const rx=s=>{const a=new WeakSet,i=c=>{if(Os(c)){if(a.has(c))return;if(Ls(c))return c;if(!("toJSON"in c)){a.add(c);const d=Dr(c)?[]:{};return zs(c,(f,m)=>{const h=i(f);!Lr(h)&&(d[m]=h)}),a.delete(c),d}}return c};return i(s)},sx=It("AsyncFunction"),lx=s=>s&&(Os(s)||vt(s))&&vt(s.then)&&vt(s.catch),Pf=((s,a)=>s?setImmediate:a?((i,c)=>(Yn.addEventListener("message",({source:d,data:f})=>{d===Yn&&f===i&&c.length&&c.shift()()},!1),d=>{c.push(d),Yn.postMessage(i,"*")}))(`axios@${Math.random()}`,[]):i=>setTimeout(i))(typeof setImmediate=="function",vt(Yn.postMessage)),ax=typeof queueMicrotask<"u"?queueMicrotask.bind(Yn):typeof process<"u"&&process.nextTick||Pf,ix=s=>s!=null&&vt(s[ma]),C={isArray:Dr,isArrayBuffer:Sf,isBuffer:Ls,isFormData:Oh,isArrayBufferView:kh,isString:jh,isNumber:bf,isBoolean:Nh,isObject:Os,isPlainObject:ta,isEmptyObject:Sh,isReadableStream:Ah,isRequest:Dh,isResponse:Fh,isHeaders:Uh,isUndefined:Lr,isDate:bh,isFile:Ch,isReactNativeBlob:_h,isReactNative:Eh,isBlob:Ph,isRegExp:Yh,isFunction:vt,isStream:Th,isURLSearchParams:zh,isTypedArray:qh,isFileList:Rh,forEach:zs,merge:bo,extend:Ih,trim:Mh,stripBOM:$h,inherits:Bh,toFlatObject:Vh,kindOf:ha,kindOfTest:It,endsWith:Hh,toArray:Wh,forEachEntry:Kh,matchAll:Qh,isHTMLForm:Jh,hasOwnProperty:Co,hasOwnProp:Co,reduceDescriptors:Ef,freezeMethods:Xh,toObjectSet:Zh,toCamelCase:Gh,noop:ex,toFiniteNumber:tx,findKey:Cf,global:Yn,isContextDefined:_f,isSpecCompliantForm:nx,toJSONObject:rx,isAsyncFn:sx,isThenable:lx,setImmediate:Pf,asap:ax,isIterable:ix},ox=C.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),ux=s=>{const a={};let i,c,d;return s&&s.split(` +`).forEach(function(m){d=m.indexOf(":"),i=m.substring(0,d).trim().toLowerCase(),c=m.substring(d+1).trim(),!(!i||a[i]&&ox[i])&&(i==="set-cookie"?a[i]?a[i].push(c):a[i]=[c]:a[i]=a[i]?a[i]+", "+c:c)}),a};function cx(s){let a=0,i=s.length;for(;aa;){const c=s.charCodeAt(i-1);if(c!==9&&c!==32)break;i-=1}return a===0&&i===s.length?s:s.slice(a,i)}const dx=new RegExp("[\\u0000-\\u0008\\u000a-\\u001f\\u007f]+","g"),fx=new RegExp("[^\\u0009\\u0020-\\u007e\\u0080-\\u00ff]+","g");function Ao(s,a){return C.isArray(s)?s.map(i=>Ao(i,a)):cx(String(s).replace(a,""))}const px=s=>Ao(s,dx),mx=s=>Ao(s,fx);function Rf(s){const a=Object.create(null);return C.forEach(s.toJSON(),(i,c)=>{a[c]=mx(i)}),a}const Md=Symbol("internals");function Ps(s){return s&&String(s).trim().toLowerCase()}function na(s){return s===!1||s==null?s:C.isArray(s)?s.map(na):px(String(s))}function hx(s){const a=Object.create(null),i=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let c;for(;c=i.exec(s);)a[c[1]]=c[2];return a}const xx=s=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(s.trim());function uo(s,a,i,c,d){if(C.isFunction(c))return c.call(this,a,i);if(d&&(a=i),!!C.isString(a)){if(C.isString(c))return a.indexOf(c)!==-1;if(C.isRegExp(c))return c.test(a)}}function gx(s){return s.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(a,i,c)=>i.toUpperCase()+c)}function yx(s,a){const i=C.toCamelCase(" "+a);["get","set","has"].forEach(c=>{Object.defineProperty(s,c+i,{__proto__:null,value:function(d,f,m){return this[c].call(this,a,d,f,m)},configurable:!0})})}let dt=class{constructor(a){a&&this.set(a)}set(a,i,c){const d=this;function f(h,x,y){const j=Ps(x);if(!j)throw new Error("header name must be a non-empty string");const _=C.findKey(d,j);(!_||d[_]===void 0||y===!0||y===void 0&&d[_]!==!1)&&(d[_||x]=na(h))}const m=(h,x)=>C.forEach(h,(y,j)=>f(y,j,x));if(C.isPlainObject(a)||a instanceof this.constructor)m(a,i);else if(C.isString(a)&&(a=a.trim())&&!xx(a))m(ux(a),i);else if(C.isObject(a)&&C.isIterable(a)){let h={},x,y;for(const j of a){if(!C.isArray(j))throw TypeError("Object iterator must return a key-value pair");h[y=j[0]]=(x=h[y])?C.isArray(x)?[...x,j[1]]:[x,j[1]]:j[1]}m(h,i)}else a!=null&&f(i,a,c);return this}get(a,i){if(a=Ps(a),a){const c=C.findKey(this,a);if(c){const d=this[c];if(!i)return d;if(i===!0)return hx(d);if(C.isFunction(i))return i.call(this,d,c);if(C.isRegExp(i))return i.exec(d);throw new TypeError("parser must be boolean|regexp|function")}}}has(a,i){if(a=Ps(a),a){const c=C.findKey(this,a);return!!(c&&this[c]!==void 0&&(!i||uo(this,this[c],c,i)))}return!1}delete(a,i){const c=this;let d=!1;function f(m){if(m=Ps(m),m){const h=C.findKey(c,m);h&&(!i||uo(c,c[h],h,i))&&(delete c[h],d=!0)}}return C.isArray(a)?a.forEach(f):f(a),d}clear(a){const i=Object.keys(this);let c=i.length,d=!1;for(;c--;){const f=i[c];(!a||uo(this,this[f],f,a,!0))&&(delete this[f],d=!0)}return d}normalize(a){const i=this,c={};return C.forEach(this,(d,f)=>{const m=C.findKey(c,f);if(m){i[m]=na(d),delete i[f];return}const h=a?gx(f):String(f).trim();h!==f&&delete i[f],i[h]=na(d),c[h]=!0}),this}concat(...a){return this.constructor.concat(this,...a)}toJSON(a){const i=Object.create(null);return C.forEach(this,(c,d)=>{c!=null&&c!==!1&&(i[d]=a&&C.isArray(c)?c.join(", "):c)}),i}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([a,i])=>a+": "+i).join(` +`)}getSetCookie(){return this.get("set-cookie")||[]}get[Symbol.toStringTag](){return"AxiosHeaders"}static from(a){return a instanceof this?a:new this(a)}static concat(a,...i){const c=new this(a);return i.forEach(d=>c.set(d)),c}static accessor(a){const c=(this[Md]=this[Md]={accessors:{}}).accessors,d=this.prototype;function f(m){const h=Ps(m);c[h]||(yx(d,m),c[h]=!0)}return C.isArray(a)?a.forEach(f):f(a),this}};dt.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]);C.reduceDescriptors(dt.prototype,({value:s},a)=>{let i=a[0].toUpperCase()+a.slice(1);return{get:()=>s,set(c){this[i]=c}}});C.freezeMethods(dt);const vx="[REDACTED ****]";function wx(s){if(C.hasOwnProp(s,"toJSON"))return!0;let a=Object.getPrototypeOf(s);for(;a&&a!==Object.prototype;){if(C.hasOwnProp(a,"toJSON"))return!0;a=Object.getPrototypeOf(a)}return!1}function kx(s,a){const i=new Set(a.map(f=>String(f).toLowerCase())),c=[],d=f=>{if(f===null||typeof f!="object"||C.isBuffer(f))return f;if(c.indexOf(f)!==-1)return;f instanceof dt&&(f=f.toJSON()),c.push(f);let m;if(C.isArray(f))m=[],f.forEach((h,x)=>{const y=d(h);C.isUndefined(y)||(m[x]=y)});else{if(!C.isPlainObject(f)&&wx(f))return c.pop(),f;m=Object.create(null);for(const[h,x]of Object.entries(f)){const y=i.has(h.toLowerCase())?vx:d(x);C.isUndefined(y)||(m[h]=y)}}return c.pop(),m};return d(s)}let le=class Tf extends Error{static from(a,i,c,d,f,m){const h=new Tf(a.message,i||a.code,c,d,f);return h.cause=a,h.name=a.name,a.status!=null&&h.status==null&&(h.status=a.status),m&&Object.assign(h,m),h}constructor(a,i,c,d,f){super(a),Object.defineProperty(this,"message",{__proto__:null,value:a,enumerable:!0,writable:!0,configurable:!0}),this.name="AxiosError",this.isAxiosError=!0,i&&(this.code=i),c&&(this.config=c),d&&(this.request=d),f&&(this.response=f,this.status=f.status)}toJSON(){const a=this.config,i=a&&C.hasOwnProp(a,"redact")?a.redact:void 0,c=C.isArray(i)&&i.length>0?kx(a,i):C.toJSONObject(a);return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:c,code:this.code,status:this.status}}};le.ERR_BAD_OPTION_VALUE="ERR_BAD_OPTION_VALUE";le.ERR_BAD_OPTION="ERR_BAD_OPTION";le.ECONNABORTED="ECONNABORTED";le.ETIMEDOUT="ETIMEDOUT";le.ECONNREFUSED="ECONNREFUSED";le.ERR_NETWORK="ERR_NETWORK";le.ERR_FR_TOO_MANY_REDIRECTS="ERR_FR_TOO_MANY_REDIRECTS";le.ERR_DEPRECATED="ERR_DEPRECATED";le.ERR_BAD_RESPONSE="ERR_BAD_RESPONSE";le.ERR_BAD_REQUEST="ERR_BAD_REQUEST";le.ERR_CANCELED="ERR_CANCELED";le.ERR_NOT_SUPPORT="ERR_NOT_SUPPORT";le.ERR_INVALID_URL="ERR_INVALID_URL";le.ERR_FORM_DATA_DEPTH_EXCEEDED="ERR_FORM_DATA_DEPTH_EXCEEDED";const jx=null;function _o(s){return C.isPlainObject(s)||C.isArray(s)}function Lf(s){return C.endsWith(s,"[]")?s.slice(0,-2):s}function co(s,a,i){return s?s.concat(a).map(function(d,f){return d=Lf(d),!i&&f?"["+d+"]":d}).join(i?".":""):a}function Nx(s){return C.isArray(s)&&!s.some(_o)}const Sx=C.toFlatObject(C,{},null,function(a){return/^is[A-Z]/.test(a)});function ga(s,a,i){if(!C.isObject(s))throw new TypeError("target must be an object");a=a||new FormData,i=C.toFlatObject(i,{metaTokens:!0,dots:!1,indexes:!1},!1,function(N,A){return!C.isUndefined(A[N])});const c=i.metaTokens,d=i.visitor||_,f=i.dots,m=i.indexes,h=i.Blob||typeof Blob<"u"&&Blob,x=i.maxDepth===void 0?100:i.maxDepth,y=h&&C.isSpecCompliantForm(a);if(!C.isFunction(d))throw new TypeError("visitor must be a function");function j(P){if(P===null)return"";if(C.isDate(P))return P.toISOString();if(C.isBoolean(P))return P.toString();if(!y&&C.isBlob(P))throw new le("Blob is not supported. Use a Buffer instead.");return C.isArrayBuffer(P)||C.isTypedArray(P)?y&&typeof Blob=="function"?new Blob([P]):Buffer.from(P):P}function _(P,N,A){let M=P;if(C.isReactNative(a)&&C.isReactNativeBlob(P))return a.append(co(A,N,f),j(P)),!1;if(P&&!A&&typeof P=="object"){if(C.endsWith(N,"{}"))N=c?N:N.slice(0,-2),P=JSON.stringify(P);else if(C.isArray(P)&&Nx(P)||(C.isFileList(P)||C.endsWith(N,"[]"))&&(M=C.toArray(P)))return N=Lf(N),M.forEach(function(Q,me){!(C.isUndefined(Q)||Q===null)&&a.append(m===!0?co([N],me,f):m===null?N:N+"[]",j(Q))}),!1}return _o(P)?!0:(a.append(co(A,N,f),j(P)),!1)}const O=[],B=Object.assign(Sx,{defaultVisitor:_,convertValue:j,isVisitable:_o});function L(P,N,A=0){if(!C.isUndefined(P)){if(A>x)throw new le("Object is too deeply nested ("+A+" levels). Max depth: "+x,le.ERR_FORM_DATA_DEPTH_EXCEEDED);if(O.indexOf(P)!==-1)throw Error("Circular reference detected in "+N.join("."));O.push(P),C.forEach(P,function(K,Q){(!(C.isUndefined(K)||K===null)&&d.call(a,K,C.isString(Q)?Q.trim():Q,N,B))===!0&&L(K,N?N.concat(Q):[Q],A+1)}),O.pop()}}if(!C.isObject(s))throw new TypeError("data must be an object");return L(s),a}function Id(s){const a={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+"};return encodeURIComponent(s).replace(/[!'()~]|%20/g,function(c){return a[c]})}function Do(s,a){this._pairs=[],s&&ga(s,this,a)}const Of=Do.prototype;Of.append=function(a,i){this._pairs.push([a,i])};Of.toString=function(a){const i=a?function(c){return a.call(this,c,Id)}:Id;return this._pairs.map(function(d){return i(d[0])+"="+i(d[1])},"").join("&")};function bx(s){return encodeURIComponent(s).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+")}function zf(s,a,i){if(!a)return s;const c=i&&i.encode||bx,d=C.isFunction(i)?{serialize:i}:i,f=d&&d.serialize;let m;if(f?m=f(a,d):m=C.isURLSearchParams(a)?a.toString():new Do(a,d).toString(c),m){const h=s.indexOf("#");h!==-1&&(s=s.slice(0,h)),s+=(s.indexOf("?")===-1?"?":"&")+m}return s}class $d{constructor(){this.handlers=[]}use(a,i,c){return this.handlers.push({fulfilled:a,rejected:i,synchronous:c?c.synchronous:!1,runWhen:c?c.runWhen:null}),this.handlers.length-1}eject(a){this.handlers[a]&&(this.handlers[a]=null)}clear(){this.handlers&&(this.handlers=[])}forEach(a){C.forEach(this.handlers,function(c){c!==null&&a(c)})}}const Fo={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1,legacyInterceptorReqResOrdering:!0},Cx=typeof URLSearchParams<"u"?URLSearchParams:Do,_x=typeof FormData<"u"?FormData:null,Ex=typeof Blob<"u"?Blob:null,Px={isBrowser:!0,classes:{URLSearchParams:Cx,FormData:_x,Blob:Ex},protocols:["http","https","file","blob","url","data"]},Uo=typeof window<"u"&&typeof document<"u",Eo=typeof navigator=="object"&&navigator||void 0,Rx=Uo&&(!Eo||["ReactNative","NativeScript","NS"].indexOf(Eo.product)<0),Tx=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope&&typeof self.importScripts=="function",Lx=Uo&&window.location.href||"http://localhost",Ox=Object.freeze(Object.defineProperty({__proto__:null,hasBrowserEnv:Uo,hasStandardBrowserEnv:Rx,hasStandardBrowserWebWorkerEnv:Tx,navigator:Eo,origin:Lx},Symbol.toStringTag,{value:"Module"})),ot={...Ox,...Px};function zx(s,a){return ga(s,new ot.classes.URLSearchParams,{visitor:function(i,c,d,f){return ot.isNode&&C.isBuffer(i)?(this.append(c,i.toString("base64")),!1):f.defaultVisitor.apply(this,arguments)},...a})}function Ax(s){return C.matchAll(/\w+|\[(\w*)]/g,s).map(a=>a[0]==="[]"?"":a[1]||a[0])}function Dx(s){const a={},i=Object.keys(s);let c;const d=i.length;let f;for(c=0;c=i.length;return m=!m&&C.isArray(d)?d.length:m,x?(C.hasOwnProp(d,m)?d[m]=C.isArray(d[m])?d[m].concat(c):[d[m],c]:d[m]=c,!h):((!C.hasOwnProp(d,m)||!C.isObject(d[m]))&&(d[m]=[]),a(i,c,d[m],f)&&C.isArray(d[m])&&(d[m]=Dx(d[m])),!h)}if(C.isFormData(s)&&C.isFunction(s.entries)){const i={};return C.forEachEntry(s,(c,d)=>{a(Ax(c),d,i,0)}),i}return null}const Er=(s,a)=>s!=null&&C.hasOwnProp(s,a)?s[a]:void 0;function Fx(s,a,i){if(C.isString(s))try{return(a||JSON.parse)(s),C.trim(s)}catch(c){if(c.name!=="SyntaxError")throw c}return(i||JSON.stringify)(s)}const As={transitional:Fo,adapter:["xhr","http","fetch"],transformRequest:[function(a,i){const c=i.getContentType()||"",d=c.indexOf("application/json")>-1,f=C.isObject(a);if(f&&C.isHTMLForm(a)&&(a=new FormData(a)),C.isFormData(a))return d?JSON.stringify(Af(a)):a;if(C.isArrayBuffer(a)||C.isBuffer(a)||C.isStream(a)||C.isFile(a)||C.isBlob(a)||C.isReadableStream(a))return a;if(C.isArrayBufferView(a))return a.buffer;if(C.isURLSearchParams(a))return i.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),a.toString();let h;if(f){const x=Er(this,"formSerializer");if(c.indexOf("application/x-www-form-urlencoded")>-1)return zx(a,x).toString();if((h=C.isFileList(a))||c.indexOf("multipart/form-data")>-1){const y=Er(this,"env"),j=y&&y.FormData;return ga(h?{"files[]":a}:a,j&&new j,x)}}return f||d?(i.setContentType("application/json",!1),Fx(a)):a}],transformResponse:[function(a){const i=Er(this,"transitional")||As.transitional,c=i&&i.forcedJSONParsing,d=Er(this,"responseType"),f=d==="json";if(C.isResponse(a)||C.isReadableStream(a))return a;if(a&&C.isString(a)&&(c&&!d||f)){const h=!(i&&i.silentJSONParsing)&&f;try{return JSON.parse(a,Er(this,"parseReviver"))}catch(x){if(h)throw x.name==="SyntaxError"?le.from(x,le.ERR_BAD_RESPONSE,this,null,Er(this,"response")):x}}return a}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:ot.classes.FormData,Blob:ot.classes.Blob},validateStatus:function(a){return a>=200&&a<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};C.forEach(["delete","get","head","post","put","patch","query"],s=>{As.headers[s]={}});function fo(s,a){const i=this||As,c=a||i,d=dt.from(c.headers);let f=c.data;return C.forEach(s,function(h){f=h.call(i,f,d.normalize(),a?a.status:void 0)}),d.normalize(),f}function Df(s){return!!(s&&s.__CANCEL__)}let Ds=class extends le{constructor(a,i,c){super(a??"canceled",le.ERR_CANCELED,i,c),this.name="CanceledError",this.__CANCEL__=!0}};function Ff(s,a,i){const c=i.config.validateStatus;!i.status||!c||c(i.status)?s(i):a(new le("Request failed with status code "+i.status,i.status>=400&&i.status<500?le.ERR_BAD_REQUEST:le.ERR_BAD_RESPONSE,i.config,i.request,i))}function Ux(s){const a=/^([-+\w]{1,25}):(?:\/\/)?/.exec(s);return a&&a[1]||""}function Mx(s,a){s=s||10;const i=new Array(s),c=new Array(s);let d=0,f=0,m;return a=a!==void 0?a:1e3,function(x){const y=Date.now(),j=c[f];m||(m=y),i[d]=x,c[d]=y;let _=f,O=0;for(;_!==d;)O+=i[_++],_=_%s;if(d=(d+1)%s,d===f&&(f=(f+1)%s),y-m{i=j,d=null,f&&(clearTimeout(f),f=null),s(...y)};return[(...y)=>{const j=Date.now(),_=j-i;_>=c?m(y,j):(d=y,f||(f=setTimeout(()=>{f=null,m(d)},c-_)))},()=>d&&m(d)]}const ia=(s,a,i=3)=>{let c=0;const d=Mx(50,250);return Ix(f=>{if(!f||typeof f.loaded!="number")return;const m=f.loaded,h=f.lengthComputable?f.total:void 0,x=h!=null?Math.min(m,h):m,y=Math.max(0,x-c),j=d(y);c=Math.max(c,x);const _={loaded:x,total:h,progress:h?x/h:void 0,bytes:y,rate:j||void 0,estimated:j&&h?(h-x)/j:void 0,event:f,lengthComputable:h!=null,[a?"download":"upload"]:!0};s(_)},i)},Bd=(s,a)=>{const i=s!=null;return[c=>a[0]({lengthComputable:i,total:s,loaded:c}),a[1]]},Vd=s=>(...a)=>C.asap(()=>s(...a)),$x=ot.hasStandardBrowserEnv?((s,a)=>i=>(i=new URL(i,ot.origin),s.protocol===i.protocol&&s.host===i.host&&(a||s.port===i.port)))(new URL(ot.origin),ot.navigator&&/(msie|trident)/i.test(ot.navigator.userAgent)):()=>!0,Bx=ot.hasStandardBrowserEnv?{write(s,a,i,c,d,f,m){if(typeof document>"u")return;const h=[`${s}=${encodeURIComponent(a)}`];C.isNumber(i)&&h.push(`expires=${new Date(i).toUTCString()}`),C.isString(c)&&h.push(`path=${c}`),C.isString(d)&&h.push(`domain=${d}`),f===!0&&h.push("secure"),C.isString(m)&&h.push(`SameSite=${m}`),document.cookie=h.join("; ")},read(s){if(typeof document>"u")return null;const a=document.cookie.split(";");for(let i=0;is instanceof dt?{...s}:s;function nr(s,a){a=a||{};const i=Object.create(null);Object.defineProperty(i,"hasOwnProperty",{__proto__:null,value:Object.prototype.hasOwnProperty,enumerable:!1,writable:!0,configurable:!0});function c(y,j,_,O){return C.isPlainObject(y)&&C.isPlainObject(j)?C.merge.call({caseless:O},y,j):C.isPlainObject(j)?C.merge({},j):C.isArray(j)?j.slice():j}function d(y,j,_,O){if(C.isUndefined(j)){if(!C.isUndefined(y))return c(void 0,y,_,O)}else return c(y,j,_,O)}function f(y,j){if(!C.isUndefined(j))return c(void 0,j)}function m(y,j){if(C.isUndefined(j)){if(!C.isUndefined(y))return c(void 0,y)}else return c(void 0,j)}function h(y,j,_){if(C.hasOwnProp(a,_))return c(y,j);if(C.hasOwnProp(s,_))return c(void 0,y)}const x={url:f,method:f,data:f,baseURL:m,transformRequest:m,transformResponse:m,paramsSerializer:m,timeout:m,timeoutMessage:m,withCredentials:m,withXSRFToken:m,adapter:m,responseType:m,xsrfCookieName:m,xsrfHeaderName:m,onUploadProgress:m,onDownloadProgress:m,decompress:m,maxContentLength:m,maxBodyLength:m,beforeRedirect:m,transport:m,httpAgent:m,httpsAgent:m,cancelToken:m,socketPath:m,allowedSocketPaths:m,responseEncoding:m,validateStatus:h,headers:(y,j,_)=>d(Hd(y),Hd(j),_,!0)};return C.forEach(Object.keys({...s,...a}),function(j){if(j==="__proto__"||j==="constructor"||j==="prototype")return;const _=C.hasOwnProp(x,j)?x[j]:d,O=C.hasOwnProp(s,j)?s[j]:void 0,B=C.hasOwnProp(a,j)?a[j]:void 0,L=_(O,B,j);C.isUndefined(L)&&_!==h||(i[j]=L)}),i}const Wx=["content-type","content-length"];function qx(s,a,i){if(i!=="content-only"){s.set(a);return}Object.entries(a).forEach(([c,d])=>{Wx.includes(c.toLowerCase())&&s.set(c,d)})}const Kx=s=>encodeURIComponent(s).replace(/%([0-9A-F]{2})/gi,(a,i)=>String.fromCharCode(parseInt(i,16))),Mf=s=>{const a=nr({},s),i=O=>C.hasOwnProp(a,O)?a[O]:void 0,c=i("data");let d=i("withXSRFToken");const f=i("xsrfHeaderName"),m=i("xsrfCookieName");let h=i("headers");const x=i("auth"),y=i("baseURL"),j=i("allowAbsoluteUrls"),_=i("url");if(a.headers=h=dt.from(h),a.url=zf(Uf(y,_,j),s.params,s.paramsSerializer),x&&h.set("Authorization","Basic "+btoa((x.username||"")+":"+(x.password?Kx(x.password):""))),C.isFormData(c)&&(ot.hasStandardBrowserEnv||ot.hasStandardBrowserWebWorkerEnv?h.setContentType(void 0):C.isFunction(c.getHeaders)&&qx(h,c.getHeaders(),i("formDataHeaderPolicy"))),ot.hasStandardBrowserEnv&&(C.isFunction(d)&&(d=d(a)),d===!0||d==null&&$x(a.url))){const B=f&&m&&Bx.read(m);B&&h.set(f,B)}return a},Qx=typeof XMLHttpRequest<"u",Jx=Qx&&function(s){return new Promise(function(i,c){const d=Mf(s);let f=d.data;const m=dt.from(d.headers).normalize();let{responseType:h,onUploadProgress:x,onDownloadProgress:y}=d,j,_,O,B,L;function P(){B&&B(),L&&L(),d.cancelToken&&d.cancelToken.unsubscribe(j),d.signal&&d.signal.removeEventListener("abort",j)}let N=new XMLHttpRequest;N.open(d.method.toUpperCase(),d.url,!0),N.timeout=d.timeout;function A(){if(!N)return;const K=dt.from("getAllResponseHeaders"in N&&N.getAllResponseHeaders()),me={data:!h||h==="text"||h==="json"?N.responseText:N.response,status:N.status,statusText:N.statusText,headers:K,config:s,request:N};Ff(function(z){i(z),P()},function(z){c(z),P()},me),N=null}"onloadend"in N?N.onloadend=A:N.onreadystatechange=function(){!N||N.readyState!==4||N.status===0&&!(N.responseURL&&N.responseURL.startsWith("file:"))||setTimeout(A)},N.onabort=function(){N&&(c(new le("Request aborted",le.ECONNABORTED,s,N)),P(),N=null)},N.onerror=function(Q){const me=Q&&Q.message?Q.message:"Network Error",fe=new le(me,le.ERR_NETWORK,s,N);fe.event=Q||null,c(fe),P(),N=null},N.ontimeout=function(){let Q=d.timeout?"timeout of "+d.timeout+"ms exceeded":"timeout exceeded";const me=d.transitional||Fo;d.timeoutErrorMessage&&(Q=d.timeoutErrorMessage),c(new le(Q,me.clarifyTimeoutError?le.ETIMEDOUT:le.ECONNABORTED,s,N)),P(),N=null},f===void 0&&m.setContentType(null),"setRequestHeader"in N&&C.forEach(Rf(m),function(Q,me){N.setRequestHeader(me,Q)}),C.isUndefined(d.withCredentials)||(N.withCredentials=!!d.withCredentials),h&&h!=="json"&&(N.responseType=d.responseType),y&&([O,L]=ia(y,!0),N.addEventListener("progress",O)),x&&N.upload&&([_,B]=ia(x),N.upload.addEventListener("progress",_),N.upload.addEventListener("loadend",B)),(d.cancelToken||d.signal)&&(j=K=>{N&&(c(!K||K.type?new Ds(null,s,N):K),N.abort(),P(),N=null)},d.cancelToken&&d.cancelToken.subscribe(j),d.signal&&(d.signal.aborted?j():d.signal.addEventListener("abort",j)));const M=Ux(d.url);if(M&&!ot.protocols.includes(M)){c(new le("Unsupported protocol "+M+":",le.ERR_BAD_REQUEST,s));return}N.send(f||null)})},Gx=(s,a)=>{if(s=s?s.filter(Boolean):[],!a&&!s.length)return;const i=new AbortController;let c=!1;const d=function(x){if(!c){c=!0,m();const y=x instanceof Error?x:this.reason;i.abort(y instanceof le?y:new Ds(y instanceof Error?y.message:y))}};let f=a&&setTimeout(()=>{f=null,d(new le(`timeout of ${a}ms exceeded`,le.ETIMEDOUT))},a);const m=()=>{s&&(f&&clearTimeout(f),f=null,s.forEach(x=>{x.unsubscribe?x.unsubscribe(d):x.removeEventListener("abort",d)}),s=null)};s.forEach(x=>x.addEventListener("abort",d));const{signal:h}=i;return h.unsubscribe=()=>C.asap(m),h},Yx=function*(s,a){let i=s.byteLength;if(i{const d=Xx(s,a);let f=0,m,h=x=>{m||(m=!0,c&&c(x))};return new ReadableStream({async pull(x){try{const{done:y,value:j}=await d.next();if(y){h(),x.close();return}let _=j.byteLength;if(i){let O=f+=_;i(O)}x.enqueue(new Uint8Array(j))}catch(y){throw h(y),y}},cancel(x){return h(x),d.return()}},{highWaterMark:2})};function eg(s){if(!s||typeof s!="string"||!s.startsWith("data:"))return 0;const a=s.indexOf(",");if(a<0)return 0;const i=s.slice(5,a),c=s.slice(a+1);if(/;base64/i.test(i)){let m=c.length;const h=c.length;for(let B=0;B=48&&L<=57||L>=65&&L<=70||L>=97&&L<=102)&&(P>=48&&P<=57||P>=65&&P<=70||P>=97&&P<=102)&&(m-=2,B+=2)}let x=0,y=h-1;const j=B=>B>=2&&c.charCodeAt(B-2)===37&&c.charCodeAt(B-1)===51&&(c.charCodeAt(B)===68||c.charCodeAt(B)===100);y>=0&&(c.charCodeAt(y)===61?(x++,y--):j(y)&&(x++,y-=3)),x===1&&y>=0&&(c.charCodeAt(y)===61||j(y))&&x++;const O=Math.floor(m/4)*3-(x||0);return O>0?O:0}if(typeof Buffer<"u"&&typeof Buffer.byteLength=="function")return Buffer.byteLength(c,"utf8");let f=0;for(let m=0,h=c.length;m=55296&&x<=56319&&m+1=56320&&y<=57343?(f+=4,m++):f+=3}else f+=3}return f}const Mo="1.16.1",qd=64*1024,{isFunction:Zl}=C,Kd=(s,...a)=>{try{return!!s(...a)}catch{return!1}},tg=s=>{const a=C.global!==void 0&&C.global!==null?C.global:globalThis,{ReadableStream:i,TextEncoder:c}=a;s=C.merge.call({skipUndefined:!0},{Request:a.Request,Response:a.Response},s);const{fetch:d,Request:f,Response:m}=s,h=d?Zl(d):typeof fetch=="function",x=Zl(f),y=Zl(m);if(!h)return!1;const j=h&&Zl(i),_=h&&(typeof c=="function"?(A=>M=>A.encode(M))(new c):async A=>new Uint8Array(await new f(A).arrayBuffer())),O=x&&j&&Kd(()=>{let A=!1;const M=new f(ot.origin,{body:new i,method:"POST",get duplex(){return A=!0,"half"}}),K=M.headers.has("Content-Type");return M.body!=null&&M.body.cancel(),A&&!K}),B=y&&j&&Kd(()=>C.isReadableStream(new m("").body)),L={stream:B&&(A=>A.body)};h&&["text","arrayBuffer","blob","formData","stream"].forEach(A=>{!L[A]&&(L[A]=(M,K)=>{let Q=M&&M[A];if(Q)return Q.call(M);throw new le(`Response type '${A}' is not supported`,le.ERR_NOT_SUPPORT,K)})});const P=async A=>{if(A==null)return 0;if(C.isBlob(A))return A.size;if(C.isSpecCompliantForm(A))return(await new f(ot.origin,{method:"POST",body:A}).arrayBuffer()).byteLength;if(C.isArrayBufferView(A)||C.isArrayBuffer(A))return A.byteLength;if(C.isURLSearchParams(A)&&(A=A+""),C.isString(A))return(await _(A)).byteLength},N=async(A,M)=>{const K=C.toFiniteNumber(A.getContentLength());return K??P(M)};return async A=>{let{url:M,method:K,data:Q,signal:me,cancelToken:fe,timeout:z,onDownloadProgress:ae,onUploadProgress:de,responseType:Y,headers:je,withCredentials:Te="same-origin",fetchOptions:ze,maxContentLength:Ee,maxBodyLength:q}=Mf(A);const ge=C.isNumber(Ee)&&Ee>-1,D=C.isNumber(q)&&q>-1;let U=d||fetch;Y=Y?(Y+"").toLowerCase():"text";let ee=Gx([me,fe&&fe.toAbortSignal()],z),G=null;const b=ee&&ee.unsubscribe&&(()=>{ee.unsubscribe()});let I;try{if(ge&&typeof M=="string"&&M.startsWith("data:")&&eg(M)>Ee)throw new le("maxContentLength size of "+Ee+" exceeded",le.ERR_BAD_RESPONSE,A,G);if(D&&K!=="get"&&K!=="head"){const oe=await N(je,Q);if(typeof oe=="number"&&isFinite(oe)&&oe>q)throw new le("Request body larger than maxBodyLength limit",le.ERR_BAD_REQUEST,A,G)}if(de&&O&&K!=="get"&&K!=="head"&&(I=await N(je,Q))!==0){let oe=new f(M,{method:"POST",body:Q,duplex:"half"}),F;if(C.isFormData(Q)&&(F=oe.headers.get("content-type"))&&je.setContentType(F),oe.body){const[ne,ke]=Bd(I,ia(Vd(de)));Q=Wd(oe.body,qd,ne,ke)}}C.isString(Te)||(Te=Te?"include":"omit");const te=x&&"credentials"in f.prototype;if(C.isFormData(Q)){const oe=je.getContentType();oe&&/^multipart\/form-data/i.test(oe)&&!/boundary=/i.test(oe)&&je.delete("content-type")}je.set("User-Agent","axios/"+Mo,!1);const pe={...ze,signal:ee,method:K.toUpperCase(),headers:Rf(je.normalize()),body:Q,duplex:"half",credentials:te?Te:void 0};G=x&&new f(M,pe);let ye=await(x?U(G,ze):U(M,pe));if(ge){const oe=C.toFiniteNumber(ye.headers.get("content-length"));if(oe!=null&&oe>Ee)throw new le("maxContentLength size of "+Ee+" exceeded",le.ERR_BAD_RESPONSE,A,G)}const Ne=B&&(Y==="stream"||Y==="response");if(B&&ye.body&&(ae||ge||Ne&&b)){const oe={};["status","statusText","headers"].forEach(Le=>{oe[Le]=ye[Le]});const F=C.toFiniteNumber(ye.headers.get("content-length")),[ne,ke]=ae&&Bd(F,ia(Vd(ae),!0))||[];let Ce=0;const De=Le=>{if(ge&&(Ce=Le,Ce>Ee))throw new le("maxContentLength size of "+Ee+" exceeded",le.ERR_BAD_RESPONSE,A,G);ne&&ne(Le)};ye=new m(Wd(ye.body,qd,De,()=>{ke&&ke(),b&&b()}),oe)}Y=Y||"text";let we=await L[C.findKey(L,Y)||"text"](ye,A);if(ge&&!B&&!Ne){let oe;if(we!=null&&(typeof we.byteLength=="number"?oe=we.byteLength:typeof we.size=="number"?oe=we.size:typeof we=="string"&&(oe=typeof c=="function"?new c().encode(we).byteLength:we.length)),typeof oe=="number"&&oe>Ee)throw new le("maxContentLength size of "+Ee+" exceeded",le.ERR_BAD_RESPONSE,A,G)}return!Ne&&b&&b(),await new Promise((oe,F)=>{Ff(oe,F,{data:we,headers:dt.from(ye.headers),status:ye.status,statusText:ye.statusText,config:A,request:G})})}catch(te){if(b&&b(),ee&&ee.aborted&&ee.reason instanceof le){const pe=ee.reason;throw pe.config=A,G&&(pe.request=G),te!==pe&&(pe.cause=te),pe}throw te&&te.name==="TypeError"&&/Load failed|fetch/i.test(te.message)?Object.assign(new le("Network Error",le.ERR_NETWORK,A,G,te&&te.response),{cause:te.cause||te}):le.from(te,te&&te.code,A,G,te&&te.response)}}},ng=new Map,If=s=>{let a=s&&s.env||{};const{fetch:i,Request:c,Response:d}=a,f=[c,d,i];let m=f.length,h=m,x,y,j=ng;for(;h--;)x=f[h],y=j.get(x),y===void 0&&j.set(x,y=h?new Map:tg(a)),j=y;return y};If();const Io={http:jx,xhr:Jx,fetch:{get:If}};C.forEach(Io,(s,a)=>{if(s){try{Object.defineProperty(s,"name",{__proto__:null,value:a})}catch{}Object.defineProperty(s,"adapterName",{__proto__:null,value:a})}});const Qd=s=>`- ${s}`,rg=s=>C.isFunction(s)||s===null||s===!1;function sg(s,a){s=C.isArray(s)?s:[s];const{length:i}=s;let c,d;const f={};for(let m=0;m`adapter ${x} `+(y===!1?"is not supported by the environment":"is not available in the build"));let h=i?m.length>1?`since : +`+m.map(Qd).join(` +`):" "+Qd(m[0]):"as no adapter specified";throw new le("There is no suitable adapter to dispatch the request "+h,"ERR_NOT_SUPPORT")}return d}const $f={getAdapter:sg,adapters:Io};function po(s){if(s.cancelToken&&s.cancelToken.throwIfRequested(),s.signal&&s.signal.aborted)throw new Ds(null,s)}function Jd(s){return po(s),s.headers=dt.from(s.headers),s.data=fo.call(s,s.transformRequest),["post","put","patch"].indexOf(s.method)!==-1&&s.headers.setContentType("application/x-www-form-urlencoded",!1),$f.getAdapter(s.adapter||As.adapter,s)(s).then(function(c){po(s),s.response=c;try{c.data=fo.call(s,s.transformResponse,c)}finally{delete s.response}return c.headers=dt.from(c.headers),c},function(c){if(!Df(c)&&(po(s),c&&c.response)){s.response=c.response;try{c.response.data=fo.call(s,s.transformResponse,c.response)}finally{delete s.response}c.response.headers=dt.from(c.response.headers)}return Promise.reject(c)})}const ya={};["object","boolean","number","function","string","symbol"].forEach((s,a)=>{ya[s]=function(c){return typeof c===s||"a"+(a<1?"n ":" ")+s}});const Gd={};ya.transitional=function(a,i,c){function d(f,m){return"[Axios v"+Mo+"] Transitional option '"+f+"'"+m+(c?". "+c:"")}return(f,m,h)=>{if(a===!1)throw new le(d(m," has been removed"+(i?" in "+i:"")),le.ERR_DEPRECATED);return i&&!Gd[m]&&(Gd[m]=!0,console.warn(d(m," has been deprecated since v"+i+" and will be removed in the near future"))),a?a(f,m,h):!0}};ya.spelling=function(a){return(i,c)=>(console.warn(`${c} is likely a misspelling of ${a}`),!0)};function lg(s,a,i){if(typeof s!="object")throw new le("options must be an object",le.ERR_BAD_OPTION_VALUE);const c=Object.keys(s);let d=c.length;for(;d-- >0;){const f=c[d],m=Object.prototype.hasOwnProperty.call(a,f)?a[f]:void 0;if(m){const h=s[f],x=h===void 0||m(h,f,s);if(x!==!0)throw new le("option "+f+" must be "+x,le.ERR_BAD_OPTION_VALUE);continue}if(i!==!0)throw new le("Unknown option "+f,le.ERR_BAD_OPTION)}}const ra={assertOptions:lg,validators:ya},Tt=ra.validators;let er=class{constructor(a){this.defaults=a||{},this.interceptors={request:new $d,response:new $d}}async request(a,i){try{return await this._request(a,i)}catch(c){if(c instanceof Error){let d={};Error.captureStackTrace?Error.captureStackTrace(d):d=new Error;const f=(()=>{if(!d.stack)return"";const m=d.stack.indexOf(` +`);return m===-1?"":d.stack.slice(m+1)})();try{if(!c.stack)c.stack=f;else if(f){const m=f.indexOf(` +`),h=m===-1?-1:f.indexOf(` +`,m+1),x=h===-1?"":f.slice(h+1);String(c.stack).endsWith(x)||(c.stack+=` +`+f)}}catch{}}throw c}}_request(a,i){typeof a=="string"?(i=i||{},i.url=a):i=a||{},i=nr(this.defaults,i);const{transitional:c,paramsSerializer:d,headers:f}=i;c!==void 0&&ra.assertOptions(c,{silentJSONParsing:Tt.transitional(Tt.boolean),forcedJSONParsing:Tt.transitional(Tt.boolean),clarifyTimeoutError:Tt.transitional(Tt.boolean),legacyInterceptorReqResOrdering:Tt.transitional(Tt.boolean)},!1),d!=null&&(C.isFunction(d)?i.paramsSerializer={serialize:d}:ra.assertOptions(d,{encode:Tt.function,serialize:Tt.function},!0)),i.allowAbsoluteUrls!==void 0||(this.defaults.allowAbsoluteUrls!==void 0?i.allowAbsoluteUrls=this.defaults.allowAbsoluteUrls:i.allowAbsoluteUrls=!0),ra.assertOptions(i,{baseUrl:Tt.spelling("baseURL"),withXsrfToken:Tt.spelling("withXSRFToken")},!0),i.method=(i.method||this.defaults.method||"get").toLowerCase();let m=f&&C.merge(f.common,f[i.method]);f&&C.forEach(["delete","get","head","post","put","patch","query","common"],L=>{delete f[L]}),i.headers=dt.concat(m,f);const h=[];let x=!0;this.interceptors.request.forEach(function(P){if(typeof P.runWhen=="function"&&P.runWhen(i)===!1)return;x=x&&P.synchronous;const N=i.transitional||Fo;N&&N.legacyInterceptorReqResOrdering?h.unshift(P.fulfilled,P.rejected):h.push(P.fulfilled,P.rejected)});const y=[];this.interceptors.response.forEach(function(P){y.push(P.fulfilled,P.rejected)});let j,_=0,O;if(!x){const L=[Jd.bind(this),void 0];for(L.unshift(...h),L.push(...y),O=L.length,j=Promise.resolve(i);_{if(!c._listeners)return;let f=c._listeners.length;for(;f-- >0;)c._listeners[f](d);c._listeners=null}),this.promise.then=d=>{let f;const m=new Promise(h=>{c.subscribe(h),f=h}).then(d);return m.cancel=function(){c.unsubscribe(f)},m},a(function(f,m,h){c.reason||(c.reason=new Ds(f,m,h),i(c.reason))})}throwIfRequested(){if(this.reason)throw this.reason}subscribe(a){if(this.reason){a(this.reason);return}this._listeners?this._listeners.push(a):this._listeners=[a]}unsubscribe(a){if(!this._listeners)return;const i=this._listeners.indexOf(a);i!==-1&&this._listeners.splice(i,1)}toAbortSignal(){const a=new AbortController,i=c=>{a.abort(c)};return this.subscribe(i),a.signal.unsubscribe=()=>this.unsubscribe(i),a.signal}static source(){let a;return{token:new Bf(function(d){a=d}),cancel:a}}};function ig(s){return function(i){return s.apply(null,i)}}function og(s){return C.isObject(s)&&s.isAxiosError===!0}const Po={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511,WebServerIsDown:521,ConnectionTimedOut:522,OriginIsUnreachable:523,TimeoutOccurred:524,SslHandshakeFailed:525,InvalidSslCertificate:526};Object.entries(Po).forEach(([s,a])=>{Po[a]=s});function Vf(s){const a=new er(s),i=jf(er.prototype.request,a);return C.extend(i,er.prototype,a,{allOwnKeys:!0}),C.extend(i,a,null,{allOwnKeys:!0}),i.create=function(d){return Vf(nr(s,d))},i}const qe=Vf(As);qe.Axios=er;qe.CanceledError=Ds;qe.CancelToken=ag;qe.isCancel=Df;qe.VERSION=Mo;qe.toFormData=ga;qe.AxiosError=le;qe.Cancel=qe.CanceledError;qe.all=function(a){return Promise.all(a)};qe.spread=ig;qe.isAxiosError=og;qe.mergeConfig=nr;qe.AxiosHeaders=dt;qe.formToJSON=s=>Af(C.isHTMLForm(s)?new FormData(s):s);qe.getAdapter=$f.getAdapter;qe.HttpStatusCode=Po;qe.default=qe;const{Axios:Qg,AxiosError:Jg,CanceledError:Gg,isCancel:Yg,CancelToken:Xg,VERSION:Zg,all:ey,Cancel:ty,isAxiosError:ny,spread:ry,toFormData:sy,AxiosHeaders:ly,HttpStatusCode:ay,formToJSON:iy,getAdapter:oy,mergeConfig:uy,create:cy}=qe,X=qe.create({baseURL:"/api/admin"});X.interceptors.request.use(s=>{const a=localStorage.getItem("token");return a&&(s.headers.Authorization=`Bearer ${a}`),s});X.interceptors.response.use(s=>s,s=>{var a;return((a=s.response)==null?void 0:a.status)===401&&(localStorage.removeItem("token"),window.location.href="/login"),Promise.reject(s)});async function ug(s,a){const i=new FormData;i.append("username",s),i.append("password",a);const{data:c}=await qe.post("/api/admin/auth/token",i);return c.access_token}function cg(){const[s,a]=v.useState(""),[i,c]=v.useState(""),[d,f]=v.useState(""),[m,h]=v.useState(!1),x=ca();async function y(j){j.preventDefault(),h(!0),f("");try{const _=await ug(s,i);localStorage.setItem("token",_),x("/dashboard")}catch{f("Credenciales incorrectas")}finally{h(!1)}}return l.jsx("div",{className:"min-h-screen bg-dark-900 flex items-center justify-center p-4",children:l.jsxs("div",{className:"w-full max-w-sm",children:[l.jsxs("div",{className:"flex items-center justify-center gap-3 mb-8",children:[l.jsx(fa,{className:"text-primary-500",size:32}),l.jsx("h1",{className:"text-3xl font-bold",children:"KiraStream"})]}),l.jsxs("form",{onSubmit:y,className:"bg-dark-800 rounded-xl p-6 space-y-4 border border-dark-700",children:[l.jsx("h2",{className:"text-center text-gray-400 text-sm",children:"Panel de Administración"}),d&&l.jsx("div",{className:"bg-red-900/30 border border-red-700 text-red-400 text-sm rounded p-2 text-center",children:d}),l.jsxs("div",{children:[l.jsx("label",{className:"block text-xs text-gray-500 mb-1",children:"Usuario"}),l.jsx("input",{value:s,onChange:j=>a(j.target.value),className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500",autoComplete:"username",required:!0})]}),l.jsxs("div",{children:[l.jsx("label",{className:"block text-xs text-gray-500 mb-1",children:"Contraseña"}),l.jsx("input",{type:"password",value:i,onChange:j=>c(j.target.value),className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500",autoComplete:"current-password",required:!0})]}),l.jsx("button",{type:"submit",disabled:m,className:"w-full bg-primary-500 hover:bg-primary-600 disabled:opacity-50 rounded-lg py-2 text-sm font-medium transition-colors",children:m?"Entrando...":"Entrar"})]})]})})}function Hf(s){var a,i,c="";if(typeof s=="string"||typeof s=="number")c+=s;else if(typeof s=="object")if(Array.isArray(s)){var d=s.length;for(a=0;a{const L=async()=>{try{const{data:N}=await X.get("/providers/notifications/all");m(N.total||0),a&&d(N.notifications||[])}catch{}};L();const P=setInterval(L,12e4);return()=>clearInterval(P)},[a]),v.useEffect(()=>{a&&_()},[a]),v.useEffect(()=>{const L=P=>{y.current&&!y.current.contains(P.target)&&i(!1)};return a&&document.addEventListener("mousedown",L),()=>document.removeEventListener("mousedown",L)},[a]);async function O(L,P,N){await X.post("/providers/notifications/dismiss",{provider_id:L,category_id:P,category_type:N}),d(A=>A.map(M=>M.provider_id===L?{...M,categories:M.categories.filter(K=>!(K.id===P&&K.type===N))}:M).filter(M=>M.categories.length>0)),m(A=>Math.max(0,A-1))}async function B(L){var N;await X.post(`/providers/notifications/dismiss-provider/${L}`),d(A=>A.filter(M=>M.provider_id!==L));const P=((N=c.find(A=>A.provider_id===L))==null?void 0:N.categories.length)??0;m(A=>Math.max(0,A-P))}return l.jsxs("div",{className:"flex h-screen overflow-hidden",children:[l.jsxs("aside",{className:"w-56 bg-dark-800 border-r border-dark-700 flex flex-col shrink-0",children:[l.jsxs("div",{className:"flex items-center gap-2 p-4 border-b border-dark-700",children:[l.jsx(fa,{className:"text-primary-500",size:22}),l.jsx("span",{className:"font-bold text-lg tracking-tight flex-1",children:"KiraStream"}),l.jsxs("div",{className:"relative",ref:y,children:[l.jsxs("button",{onClick:()=>i(L=>!L),className:Mt("relative p-1 rounded-lg transition-colors",a?"bg-dark-600 text-yellow-400":"text-gray-500 hover:text-yellow-400 hover:bg-dark-700"),title:"Notificaciones",children:[l.jsx(gf,{size:16}),f>0&&l.jsx("span",{className:"absolute -top-1 -right-1 bg-yellow-500 text-black text-[9px] font-bold rounded-full w-4 h-4 flex items-center justify-center",children:f>99?"99+":f})]}),a&&l.jsxs("div",{className:"absolute left-0 top-full mt-2 w-80 bg-dark-800 border border-dark-700 rounded-xl shadow-2xl z-50 flex flex-col max-h-[70vh]",children:[l.jsxs("div",{className:"p-3 border-b border-dark-700 flex items-center justify-between shrink-0",children:[l.jsx("span",{className:"font-semibold text-sm",children:"Nuevas categorías"}),l.jsx("button",{onClick:()=>i(!1),className:"text-gray-500 hover:text-white",children:l.jsx(Ln,{size:14})})]}),h?l.jsx("div",{className:"flex items-center justify-center p-8",children:l.jsx(Ge,{size:18,className:"animate-spin text-gray-400"})}):c.length===0?l.jsx("div",{className:"p-6 text-center text-gray-500 text-sm",children:"Sin novedades pendientes ✓"}):l.jsx("div",{className:"overflow-y-auto flex-1 p-2 space-y-3",children:c.map(L=>l.jsxs("div",{className:"bg-dark-900/50 rounded-lg border border-dark-700 overflow-hidden",children:[l.jsxs("div",{className:"flex items-center justify-between px-3 py-2 bg-dark-700/40",children:[l.jsx("span",{className:"text-xs font-semibold text-gray-300 truncate",children:L.provider_name}),l.jsxs("div",{className:"flex items-center gap-1 shrink-0",children:[l.jsx("span",{className:"text-xs text-gray-500",children:L.categories.length}),l.jsx("button",{onClick:()=>B(L.provider_id),className:"text-xs text-gray-500 hover:text-red-400 ml-1 px-1.5 py-0.5 rounded hover:bg-dark-600",title:"Descartar todas",children:"Ignorar todas"})]})]}),l.jsx("div",{className:"p-2 space-y-1 max-h-48 overflow-y-auto",children:L.categories.map(P=>l.jsxs("div",{className:"flex items-center gap-2 px-2 py-1 rounded hover:bg-dark-700 group",children:[l.jsx("span",{className:"text-xs flex-1 truncate",children:P.name}),l.jsx("span",{className:`text-[10px] px-1 py-0.5 rounded border shrink-0 ${pg[P.type]}`,children:fg[P.type]}),l.jsx("button",{onClick:()=>O(L.provider_id,P.id,P.type),className:"opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-opacity shrink-0",title:"Descartar",children:l.jsx(Ln,{size:12})})]},`${P.id}_${P.type}`))}),l.jsx("div",{className:"px-3 py-2 border-t border-dark-700",children:l.jsxs("button",{onClick:()=>{i(!1),s("/providers")},className:"text-xs text-primary-400 hover:text-primary-300 flex items-center gap-1",children:["Ir a Proveedores para sincronizar ",l.jsx(No,{size:11})]})})]},L.provider_id))})]})]})]}),l.jsx("nav",{className:"flex-1 p-3 space-y-1",children:dg.map(({to:L,icon:P,label:N})=>l.jsxs(th,{to:L,className:({isActive:A})=>Mt("flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",A?"bg-primary-500/20 text-primary-400":"text-gray-400 hover:bg-dark-700 hover:text-gray-100"),children:[l.jsx(P,{size:17}),N]},L))}),l.jsxs("button",{onClick:j,className:"flex items-center gap-3 p-4 text-sm text-gray-500 hover:text-red-400 border-t border-dark-700 transition-colors",children:[l.jsx(mh,{size:16})," Cerrar sesión"]})]}),l.jsx("main",{className:"flex-1 overflow-auto bg-dark-900",children:l.jsx($0,{})})]})}function hg(s){return s<1024?s+" B":s<1048576?(s/1024).toFixed(1)+" KB":s<1073741824?(s/1048576).toFixed(1)+" MB":(s/1073741824).toFixed(2)+" GB"}function mo(s){return s<=0?"—":s<1024?s.toFixed(0)+" B/s":s<1048576?(s/1024).toFixed(0)+" KB/s":(s/1048576).toFixed(1)+" MB/s"}function xg(s){const a=Math.floor(Date.now()/1e3-s),i=Math.floor(a/3600),c=Math.floor(a%3600/60),d=a%60;return i>0?`${i}h ${c}m`:c>0?`${c}m ${d}s`:`${d}s`}function gg(s){if(!s.width||!s.height)return null;const a=s.width>=3840?"4K":s.width>=1920?"FHD":s.width>=1280?"HD":s.width>=720?"SD":"LD";return`${s.width}×${s.height} (${a})`}function yg({info:s}){if(!s||Object.keys(s).length===0)return l.jsx("span",{className:"text-gray-600 text-xs italic",children:"Detectando..."});const a=gg(s),i=s.fps?`${s.fps} fps`:null,c=s.video_codec?s.video_codec.toUpperCase():null,d=s.audio_codec?s.audio_codec.toUpperCase():null,f=s.bitrate_bps?`${(s.bitrate_bps/1e6).toFixed(1)} Mb/s`:null;return l.jsxs("div",{className:"space-y-0.5",children:[a&&l.jsxs("div",{className:"text-gray-200 font-mono text-xs",children:[a,i?` · ${i}`:""]}),l.jsxs("div",{className:"flex gap-1 flex-wrap",children:[c&&l.jsx("span",{className:"bg-blue-900/40 text-blue-300 text-[10px] px-1.5 py-0.5 rounded border border-blue-800",children:c}),d&&l.jsxs("span",{className:"bg-purple-900/40 text-purple-300 text-[10px] px-1.5 py-0.5 rounded border border-purple-800",children:[d,s.audio_channels?` ${s.audio_channels}ch`:""]}),f&&l.jsx("span",{className:"text-gray-500 text-[10px]",children:f})]})]})}function vg({status:s,reconnectCount:a}){return!s||s==="ok"?a&&a>0?l.jsxs("span",{className:"inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-yellow-900/40 text-yellow-300 border border-yellow-800",title:`${a} reconexión(es)`,children:[l.jsx(Ge,{size:9})," ",a]}):l.jsxs("span",{className:"inline-flex items-center gap-1 text-[10px] text-green-500",children:[l.jsx("span",{className:"w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse inline-block"})," OK"]}):s==="connecting"?l.jsxs("span",{className:"inline-flex items-center gap-1 text-[10px] text-gray-400",children:[l.jsx(Ge,{size:9,className:"animate-spin"})," Conectando"]}):s==="reconnecting"?l.jsxs("span",{className:"inline-flex items-center gap-1 text-[10px] text-orange-400",children:[l.jsx(Ge,{size:9,className:"animate-spin"})," Reconectando ",a?`(×${a})`:""]}):l.jsxs("span",{className:"inline-flex items-center gap-1 text-[10px] text-red-400",children:[l.jsx(Zn,{size:9})," ",s]})}function wg({health:s,desyncs:a,corrections:i}){const c=i!=null&&i>0,d=a!=null&&a>0,f=s==="error"?l.jsxs("span",{className:"inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-red-900/40 text-red-300 border border-red-800",children:[l.jsx(Zn,{size:9})," A/V error"]}):s==="warning"?l.jsxs("span",{className:"inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-yellow-900/40 text-yellow-300 border border-yellow-800",children:[l.jsx(vf,{size:9})," A/V aviso"]}):l.jsxs("span",{className:"inline-flex items-center gap-1 text-[10px] text-emerald-500",children:[l.jsx(yh,{size:9})," A/V ok"]});return l.jsxs("div",{className:"flex flex-wrap items-center gap-1 mt-1",children:[f,c&&l.jsxs("span",{className:"inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-violet-900/40 text-violet-300 border border-violet-800",title:"Correcciones automáticas del monitor de salud",children:[l.jsx(Ge,{size:9})," ×",i]}),d&&l.jsxs("span",{className:"text-[10px] text-gray-600 tabular-nums",title:"Eventos A/V en stderr de ffmpeg (informativos)",children:[a," ev."]})]})}function kg({pct:s,serverSecs:a,serverCapacity:i}){const c=s??0,d=c>=70?"bg-red-500":c>=40?"bg-yellow-400":"bg-emerald-500",f=c>=70?"text-red-400":c>=40?"text-yellow-400":"text-emerald-500",m=a??0,h=((i??0)/1048576).toFixed(0),x=m>.5?`${m.toFixed(1)}s`:"ok",y=m>10?"text-yellow-400":"text-emerald-500",j=`Buffer servidor: ${m>0?m.toFixed(1)+"s de cobertura":"0 — normal en reposo"}. Capacidad máx: ${h} MB. El buffer se activa durante reconexiones del proveedor.`;return l.jsxs("div",{className:"mt-1.5 space-y-0.5",children:[l.jsxs("div",{className:"flex items-center justify-between",title:`Cola cliente: ${c.toFixed(0)}% — 0%=consume sin retraso, 100%=cliente lento`,children:[l.jsx("span",{className:"text-[10px] text-gray-500",children:"Cola cliente"}),l.jsxs("span",{className:`text-[10px] font-mono tabular-nums ${f}`,children:[c.toFixed(0),"%"]})]}),l.jsx("div",{className:"w-full h-1.5 rounded-full bg-dark-700 overflow-hidden",children:l.jsx("div",{className:`h-full rounded-full transition-all duration-700 ${d}`,style:{width:`${Math.min(c,100)}%`}})}),l.jsxs("div",{className:"flex items-center justify-between pt-0.5",title:j,children:[l.jsxs("span",{className:"text-[10px] text-gray-500",children:["Reserva (",h," MB)"]}),l.jsx("span",{className:`text-[10px] font-mono tabular-nums ${y}`,children:x})]})]})}const jg={live:l.jsx(Tr,{size:12,className:"text-blue-400"}),movie:l.jsx(Gn,{size:12,className:"text-purple-400"}),series:l.jsx(yf,{size:12,className:"text-green-400"})},Ng={live:"Directo",movie:"Película",series:"Serie"},Sg={live:"bg-blue-900/30 border-blue-800 text-blue-300",movie:"bg-purple-900/30 border-purple-800 text-purple-300",series:"bg-green-900/30 border-green-800 text-green-300"};function bg(){const[s,a]=v.useState([]),[i,c]=v.useState([]),[d,f]=v.useState(!1),[m,h]=v.useState(null),[x,y]=v.useState(null),j=v.useRef(null);v.useEffect(()=>{const ae=`${window.location.protocol==="https:"?"wss":"ws"}://${window.location.host}/api/admin/monitoring/ws`,de=new WebSocket(ae);return j.current=de,de.onopen=()=>f(!0),de.onclose=()=>f(!1),de.onmessage=Y=>{const je=JSON.parse(Y.data);(je.type==="state"||je.type==="update")&&a(je.data)},()=>de.close()},[]),v.useEffect(()=>{X.get("/monitoring/slots").then(z=>c(z.data)).catch(()=>{})},[s]);async function _(z,ae){var de,Y;if(confirm(`¿Cortar la conexión de "${ae}"?`)){h(z),y(null);try{await X.post("/monitoring/kill",{client_id:z})}catch(je){const Te=(Y=(de=je==null?void 0:je.response)==null?void 0:de.data)==null?void 0:Y.detail;y(Te||"Error al cortar la conexión"),setTimeout(()=>y(null),4e3)}finally{h(null)}}}const O=s.reduce((z,ae)=>z+ae.client_count,0),B=i.filter(z=>z.status==="busy").length,L=s.reduce((z,ae)=>z+(ae.bps_down||0),0),P=s.reduce((z,ae)=>z+(ae.ffmpeg_cpu_pct??0),0),N=s.some(z=>z.status==="reconnecting"),A=s.filter(z=>!z.av_health||z.av_health==="ok").length,M=s.filter(z=>z.av_health==="warning").length,K=s.filter(z=>z.av_health==="error").length,Q=s.reduce((z,ae)=>z+(ae.corrections??0),0),me=K>0?"text-red-400":M>0?"text-yellow-400":"text-emerald-400",fe=s.length===0?"—":K>0?`${K} error${K>1?"es":""}`:M>0?`${M} aviso${M>1?"s":""}`:`${A} ok`;return l.jsxs("div",{className:"p-6 space-y-6",children:[l.jsxs("div",{className:"flex items-center justify-between",children:[l.jsx("h1",{className:"text-xl font-bold",children:"Dashboard"}),l.jsxs("div",{className:"flex items-center gap-3",children:[N&&l.jsxs("span",{className:"flex items-center gap-1.5 text-xs text-orange-400 bg-orange-900/30 border border-orange-800 px-2 py-1 rounded-lg",children:[l.jsx(Ge,{size:11,className:"animate-spin"}),"Reconectando stream(s)..."]}),K>0&&l.jsxs("span",{className:"flex items-center gap-1.5 text-xs text-red-400 bg-red-900/30 border border-red-800 px-2 py-1 rounded-lg",children:[l.jsx(Zn,{size:11}),K," stream",K>1?"s":""," con error A/V"]}),l.jsxs("div",{className:`flex items-center gap-2 text-xs ${d?"text-green-400":"text-red-400"}`,children:[l.jsx("span",{className:`w-2 h-2 rounded-full ${d?"bg-green-400 animate-pulse":"bg-red-400"}`}),d?"Conectado en tiempo real":"Desconectado"]})]})]}),x&&l.jsx("div",{className:"bg-red-900/40 border border-red-700 rounded-lg px-4 py-2 text-sm text-red-300",children:x}),l.jsxs("div",{className:"grid grid-cols-2 lg:grid-cols-6 gap-4",children:[l.jsx(Pr,{icon:l.jsx(oh,{size:20,className:"text-blue-400"}),label:"Streams activos",value:s.length}),l.jsx(Pr,{icon:l.jsx(aa,{size:20,className:"text-green-400"}),label:"Usuarios conectados",value:O}),l.jsx(Pr,{icon:l.jsx(zo,{size:20,className:"text-orange-400"}),label:"Slots ocupados",value:`${B}/${i.length}`}),l.jsx(Pr,{icon:l.jsx(fa,{size:20,className:"text-purple-400"}),label:"↓ Total proveedor",value:mo(L)}),l.jsx(Pr,{icon:l.jsx(Dd,{size:20,className:P>80?"text-red-400":P>40?"text-yellow-400":"text-cyan-400"}),label:"ffmpeg CPU total",value:`${P.toFixed(2)}%`}),l.jsx(Pr,{icon:l.jsx(vf,{size:20,className:me}),label:`Salud A/V${Q>0?` · ${Q} corr.`:""}`,value:fe,valueClass:me})]}),i.length>0&&l.jsxs("div",{className:"bg-dark-800 rounded-xl border border-dark-700 p-4",children:[l.jsx("h2",{className:"text-sm font-semibold text-gray-400 mb-3",children:"Estado de Slots del Proveedor"}),l.jsx("div",{className:"flex flex-wrap gap-2",children:i.map(z=>l.jsxs("div",{className:`px-3 py-1.5 rounded-lg text-xs font-medium border ${z.status==="busy"?"bg-orange-900/30 border-orange-700 text-orange-300":"bg-green-900/30 border-green-800 text-green-400"}`,children:[l.jsx("span",{className:"font-bold",children:z.name}),l.jsx("span",{className:"ml-2 opacity-70",children:z.status==="busy"?`ch#${z.channel_id}`:"libre"})]},z.id))})]}),l.jsxs("div",{className:"bg-dark-800 rounded-xl border border-dark-700",children:[l.jsx("div",{className:"p-4 border-b border-dark-700",children:l.jsx("h2",{className:"text-sm font-semibold text-gray-300",children:"Streams en Reproducción"})}),s.length===0?l.jsx("div",{className:"p-8 text-center text-gray-500 text-sm",children:"No hay streams activos"}):l.jsx("div",{className:"overflow-x-auto",children:l.jsxs("table",{className:"w-full text-sm",children:[l.jsx("thead",{children:l.jsxs("tr",{className:"text-left text-xs text-gray-500 border-b border-dark-700",children:[l.jsx("th",{className:"px-4 py-2",children:"Tipo"}),l.jsx("th",{className:"px-4 py-2",children:"Canal"}),l.jsx("th",{className:"px-4 py-2",children:"Proveedor"}),l.jsx("th",{className:"px-4 py-2",children:"Stream"}),l.jsx("th",{className:"px-4 py-2",children:"Estado"}),l.jsx("th",{className:"px-4 py-2",children:"Salud A/V"}),l.jsx("th",{className:"px-4 py-2",children:"Usuarios"}),l.jsx("th",{className:"px-4 py-2",children:"↓ / ↑"}),l.jsx("th",{className:"px-4 py-2",children:"Tiempo"}),l.jsx("th",{className:"px-4 py-2",children:"Datos"})]})}),l.jsx("tbody",{children:s.map((z,ae)=>l.jsxs("tr",{className:"border-b border-dark-700 hover:bg-dark-700/30 align-top",children:[l.jsx("td",{className:"px-4 py-2.5",children:l.jsxs("span",{className:`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded border ${Sg[z.stream_type]}`,children:[jg[z.stream_type],Ng[z.stream_type]]})}),l.jsxs("td",{className:"px-4 py-2.5",children:[l.jsx("span",{className:"font-medium text-gray-200 text-xs leading-tight block max-w-[160px] truncate",title:z.channel_name,children:z.channel_name}),l.jsxs("span",{className:"text-gray-600 text-xs",children:["#",z.channel_id]})]}),l.jsx("td",{className:"px-4 py-2.5",children:l.jsx("span",{className:"bg-orange-900/40 text-orange-300 text-xs px-2 py-0.5 rounded",children:z.provider_name||`#${z.provider_account_id}`})}),l.jsx("td",{className:"px-4 py-2.5 min-w-[160px]",children:z.stream_info?l.jsx(yg,{info:z.stream_info}):l.jsx("span",{className:"text-gray-600 text-xs italic",children:"—"})}),l.jsxs("td",{className:"px-4 py-2.5",children:[l.jsx(vg,{status:z.status,reconnectCount:z.reconnect_count}),z.ffmpeg_pid&&l.jsxs("div",{className:"mt-1.5 flex items-center gap-1.5",children:[l.jsxs("span",{className:"inline-flex items-center gap-1 bg-cyan-900/40 text-cyan-300 text-[10px] px-1.5 py-0.5 rounded border border-cyan-800 font-mono",children:[l.jsx(Dd,{size:9}),"ffmpeg"]}),z.ffmpeg_cpu_pct!==void 0&&l.jsxs("span",{className:`font-mono text-[10px] tabular-nums ${z.ffmpeg_cpu_pct>80?"text-red-400":z.ffmpeg_cpu_pct>40?"text-yellow-400":"text-gray-400"}`,children:[z.ffmpeg_cpu_pct.toFixed(2),"%"]})]})]}),l.jsxs("td",{className:"px-4 py-2.5 min-w-[140px]",children:[l.jsx(wg,{health:z.av_health,desyncs:z.av_desync_count,corrections:z.corrections}),l.jsx(kg,{pct:z.buffer_pct,serverSecs:z.buffer_server_secs,serverCapacity:z.buffer_capacity})]}),l.jsx("td",{className:"px-4 py-2.5",children:l.jsx("div",{className:"flex flex-col gap-1",children:z.clients.map(de=>l.jsxs("div",{className:"flex items-center gap-1.5",children:[l.jsx("span",{className:"text-gray-300 text-xs font-medium",children:de.username}),l.jsx("button",{onClick:()=>_(de.client_id,de.username),disabled:m===de.client_id,title:`Cortar conexión de ${de.username}`,className:"p-0.5 rounded hover:bg-red-900/40 text-gray-600 hover:text-red-400 transition-colors disabled:opacity-40 shrink-0",children:l.jsx(Ln,{size:11})})]},de.client_id))})}),l.jsxs("td",{className:"px-4 py-2.5 font-mono text-xs",children:[l.jsxs("div",{className:"text-cyan-400",children:["↓ ",mo(z.bps_down)]}),l.jsxs("div",{className:"text-green-400",children:["↑ ",mo(z.bps_up)]})]}),l.jsxs("td",{className:"px-4 py-2.5 text-gray-400 font-mono text-xs",children:[l.jsx(So,{size:11,className:"inline mr-1"}),xg(z.started_at)]}),l.jsx("td",{className:"px-4 py-2.5 text-gray-500 text-xs font-mono",children:hg(z.bytes_pumped)})]},ae))})]})})]})]})}function Pr({icon:s,label:a,value:i,valueClass:c}){return l.jsxs("div",{className:"bg-dark-800 border border-dark-700 rounded-xl p-4 flex items-center gap-4",children:[l.jsx("div",{className:"bg-dark-700 p-2.5 rounded-lg",children:s}),l.jsxs("div",{children:[l.jsx("p",{className:"text-xs text-gray-500",children:a}),l.jsx("p",{className:`text-2xl font-bold ${c??""}`,children:i})]})]})}function Cg(s){if(!s)return null;const a=new Date(s),i=Math.ceil((a.getTime()-Date.now())/864e5);return i<0?{label:"Expirado",cls:"text-red-400 bg-red-900/20 border border-red-800"}:i<=7?{label:`Caduca en ${i}d`,cls:"text-red-400 bg-red-900/20 border border-red-800"}:i<=30?{label:`Caduca en ${i}d`,cls:"text-yellow-400 bg-yellow-900/20 border border-yellow-800"}:{label:`Caduca: ${a.toLocaleDateString("es-ES")}`,cls:"text-green-400 bg-green-900/20 border border-green-800"}}const _g=[{key:"live",label:"Canales en directo"},{key:"movie",label:"Películas (VOD)"},{key:"series",label:"Series"}],Yd=[{value:0,label:"Desactivado"},{value:6,label:"Cada 6 horas"},{value:12,label:"Cada 12 horas"},{value:24,label:"Cada 24 horas"}],Xd={name:"",base_url:"",username:"",password:"",max_connections:1,auto_sync_hours:0};function Eg(){const[s,a]=v.useState([]),[i,c]=v.useState(Xd),[d,f]=v.useState(!1),[m,h]=v.useState(new Set),[x,y]=v.useState({}),[j,_]=v.useState({}),[O,B]=v.useState(null),[L,P]=v.useState({}),N=v.useRef({}),[A,M]=v.useState(null),[K,Q]=v.useState(!1),[me,fe]=v.useState({live:[],movie:[],series:[]}),[z,ae]=v.useState({live:new Set,movie:new Set,series:new Set}),[de,Y]=v.useState(""),[je,Te]=v.useState({}),[ze,Ee]=v.useState(null),[q,ge]=v.useState({name:"",base_url:"",username:"",password:"",max_connections:1,auto_sync_hours:0}),[D,U]=v.useState(null),[ee,G]=v.useState(!1),[b,I]=v.useState([]),[te,pe]=v.useState(new Set),[ye,Ne]=v.useState(""),[we,oe]=v.useState({}),[F,ne]=v.useState(new Set),[ke,Ce]=v.useState(null),[De,Le]=v.useState({url:"",priority:10,is_active:!0}),[Fr,Ur]=v.useState(new Set);async function wt(){const{data:w}=await X.get("/providers/");return a(w),w}v.useEffect(()=>(wt().then(async w=>{for(const $ of w)try{const{data:ue}=await X.get(`/providers/${$.id}/sync/status`);ue.status==="running"&&(h(Se=>new Set(Se).add($.id)),y(Se=>({...Se,[$.id]:ue.progress||"Sincronizando..."})),rr($.id))}catch{}}),()=>{Object.keys(N.current).forEach(w=>{N.current[+w]=!1})}),[]);function rr(w){N.current[w]=!0;const $=async()=>{if(N.current[w])try{const{data:ue}=await X.get(`/providers/${w}/sync/status`);y(Se=>({...Se,[w]:ue.progress||""})),_(Se=>({...Se,[w]:ue.stats||{}})),ue.status==="running"?setTimeout($,1500):(N.current[w]=!1,h(Se=>{const Ae=new Set(Se);return Ae.delete(w),Ae}),y(Se=>({...Se,[w]:ue.status==="done"?"✓ Sync completado":`Error: ${ue.progress}`})),wt())}catch{N.current[w]=!1,h(ue=>{const Se=new Set(ue);return Se.delete(w),Se})}};$()}async function Mr(w){w.preventDefault(),await X.post("/providers/",i),c(Xd),f(!1),wt()}async function On(w){confirm("¿Eliminar este proveedor y todos sus canales?")&&(await X.delete(`/providers/${w}`),wt())}async function an(w){B(w);try{const{data:$}=await X.post(`/providers/${w}/test`);P(ue=>({...ue,[w]:$.connected})),$.connected&&wt()}finally{B(null)}}function Ir(w){Ee(w),ge({name:w.name,base_url:w.base_url,username:w.username,password:"",max_connections:w.max_connections,auto_sync_hours:w.auto_sync_hours})}async function Fs(){var w,$;if(ze)try{await X.put(`/providers/${ze.id}`,q),Ee(null),wt()}catch(ue){alert("Error al guardar: "+((($=(w=ue.response)==null?void 0:w.data)==null?void 0:$.detail)||ue.message))}}async function Us(w){var $,ue,Se;M(w),Q(!0),Y(""),Te({});try{const[Ae,$e]=await Promise.all([X.get(`/providers/${w.id}/sync/categories`),X.get(`/providers/${w.id}/synced-category-ids`)]);fe(Ae.data);let lr=$e.data.live,qr=$e.data.movie,un=$e.data.series;if(w.last_sync_categories)try{const $t=JSON.parse(w.last_sync_categories);((($=$t.live)==null?void 0:$.length)??0)+(((ue=$t.movie)==null?void 0:ue.length)??0)+(((Se=$t.series)==null?void 0:Se.length)??0)>0&&(lr=$t.live??[],qr=$t.movie??[],un=$t.series??[])}catch{}ae({live:new Set(lr),movie:new Set(qr),series:new Set(un)})}catch{M(null)}finally{Q(!1)}}async function Ms(){if(!A)return;const w=A.id;M(null),h($=>new Set($).add(w)),y($=>({...$,[w]:"Iniciando..."}));try{await X.post(`/providers/${w}/sync`,{live_category_ids:Array.from(z.live),movie_category_ids:Array.from(z.movie),series_category_ids:Array.from(z.series)}),rr(w)}catch{h($=>{const ue=new Set($);return ue.delete(w),ue}),y($=>({...$,[w]:"Error al iniciar sync"}))}}function Is(w,$){ae(ue=>{const Se=new Set(ue[w]);return Se.has($)?Se.delete($):Se.add($),{...ue,[w]:Se}})}function $r(w,$){ae(ue=>({...ue,[w]:new Set($.map(Se=>Se.id))}))}function sr(w){ae($=>({...$,[w]:new Set}))}async function $s(w){U(w),G(!0),Ne(""),pe(new Set);try{const{data:$}=await X.get(`/providers/${w.id}/available-updates`);I($.new_categories||[])}catch{I([])}finally{G(!1)}}async function zn(w){try{const{data:$}=await X.get(`/providers/${w}/available-updates`);oe(ue=>({...ue,[w]:$.total||0}))}catch{}}v.useEffect(()=>{s.forEach(w=>{w.last_sync_at&&zn(w.id)})},[s.length]);async function An(){if(!D||te.size===0)return;const w=D.id,$=[],ue=[],Se=[];b.filter(Ae=>te.has(Ae.id+"_"+Ae.type)).forEach(Ae=>{Ae.type==="live"?$.push(Ae.id):Ae.type==="movie"?ue.push(Ae.id):Ae.type==="series"&&Se.push(Ae.id)}),U(null),h(Ae=>new Set(Ae).add(w)),y(Ae=>({...Ae,[w]:"Iniciando..."}));try{await X.post(`/providers/${w}/sync`,{live_category_ids:$.length>0?$:void 0,movie_category_ids:ue.length>0?ue:void 0,series_category_ids:Se.length>0?Se:void 0}),rr(w)}catch{h(Ae=>{const $e=new Set(Ae);return $e.delete(w),$e}),y(Ae=>({...Ae,[w]:"Error al iniciar sync"}))}}function va(w){ne($=>{const ue=new Set($);return ue.has(w)?ue.delete(w):ue.add(w),ue})}async function Bs(w){await X.post(`/providers/${w}/urls`,De),Ce(null),Le({url:"",priority:10,is_active:!0}),wt()}async function Vs(w,$){confirm("¿Eliminar esta URL de failover?")&&(await X.delete(`/providers/${w}/urls/${$}`),wt())}async function wa(w){Ur($=>new Set($).add(w));try{await X.post(`/providers/${w}/urls/check-all`),wt()}finally{Ur($=>{const ue=new Set($);return ue.delete(w),ue})}}function Br(w){return w.status==="ok"?l.jsxs("span",{className:"text-green-400 text-xs font-mono",children:["● ",w.response_ms!=null?`${w.response_ms}ms`:"OK"]}):w.status==="error"?l.jsx("span",{className:"text-red-400 text-xs font-mono",children:"● Error"}):w.status==="timeout"?l.jsx("span",{className:"text-yellow-400 text-xs font-mono",children:"● Timeout"}):l.jsx("span",{className:"text-gray-500 text-xs font-mono",children:"○ Desconocido"})}const on=Object.values(z).reduce((w,$)=>w+$.size,0),Vr=Object.values(me).reduce((w,$)=>w+$.length,0),Hr={live:"Directo",movie:"Película",series:"Serie"},Wr={live:"bg-blue-900/30 text-blue-300 border-blue-800",movie:"bg-purple-900/30 text-purple-300 border-purple-800",series:"bg-green-900/30 text-green-300 border-green-800"},Qt=b.filter(w=>!ye||w.name.toLowerCase().includes(ye.toLowerCase()));return l.jsxs("div",{className:"p-6 space-y-5",children:[l.jsxs("div",{className:"flex items-center justify-between",children:[l.jsx("h1",{className:"text-xl font-bold",children:"Proveedores IPTV"}),l.jsxs("button",{onClick:()=>f(w=>!w),className:"flex items-center gap-2 bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium transition-colors",children:[l.jsx(tr,{size:16})," Añadir proveedor"]})]}),d&&l.jsxs("form",{onSubmit:Mr,className:"bg-dark-800 border border-dark-700 rounded-xl p-5 space-y-3",children:[l.jsx("h3",{className:"font-semibold text-sm mb-2",children:"Nueva cuenta de proveedor"}),l.jsxs("div",{className:"grid grid-cols-2 gap-3",children:[l.jsx(Jn,{label:"Nombre",value:i.name,onChange:w=>c($=>({...$,name:w})),required:!0}),l.jsx(Jn,{label:"URL base (ej: http://provider.com:8080)",value:i.base_url,onChange:w=>c($=>({...$,base_url:w})),required:!0}),l.jsx(Jn,{label:"Usuario",value:i.username,onChange:w=>c($=>({...$,username:w})),required:!0}),l.jsx(Jn,{label:"Contraseña",value:i.password,onChange:w=>c($=>({...$,password:w})),required:!0}),l.jsxs("div",{children:[l.jsx("label",{className:"block text-xs text-gray-500 mb-1",children:"Conexiones máx."}),l.jsx("input",{type:"number",min:1,max:100,value:i.max_connections,onChange:w=>c($=>({...$,max_connections:+w.target.value})),className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"})]}),l.jsxs("div",{children:[l.jsx("label",{className:"block text-xs text-gray-500 mb-1",children:"Auto-sincronización"}),l.jsx("select",{value:i.auto_sync_hours,onChange:w=>c($=>({...$,auto_sync_hours:+w.target.value})),className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm",children:Yd.map(w=>l.jsx("option",{value:w.value,children:w.label},w.value))})]})]}),l.jsxs("div",{className:"flex gap-2 pt-1",children:[l.jsx("button",{type:"submit",className:"bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium",children:"Guardar"}),l.jsx("button",{type:"button",onClick:()=>f(!1),className:"bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm",children:"Cancelar"})]})]}),A&&l.jsx("div",{className:"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4",children:l.jsxs("div",{className:"bg-dark-800 rounded-xl border border-dark-700 w-full max-w-2xl flex flex-col max-h-[90vh]",children:[l.jsxs("div",{className:"p-4 border-b border-dark-700 flex items-center justify-between shrink-0",children:[l.jsxs("div",{children:[l.jsxs("h2",{className:"font-semibold",children:["Seleccionar categorías — ",l.jsx("span",{className:"text-primary-400",children:A.name})]}),!K&&l.jsxs("p",{className:"text-xs text-gray-500 mt-0.5",children:[on," de ",Vr," categorías seleccionadas"]})]}),l.jsx("button",{onClick:()=>M(null),className:"text-gray-500 hover:text-white",children:"✕"})]}),K?l.jsx("div",{className:"flex-1 flex items-center justify-center p-12",children:l.jsxs("div",{className:"text-center text-gray-400",children:[l.jsx(Ge,{size:24,className:"animate-spin mx-auto mb-3"}),l.jsx("p",{className:"text-sm",children:"Obteniendo categorías del proveedor..."})]})}):l.jsxs(l.Fragment,{children:[l.jsx("div",{className:"p-3 border-b border-dark-700 shrink-0",children:l.jsxs("div",{className:"relative",children:[l.jsx(Kt,{size:14,className:"absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"}),l.jsx("input",{value:de,onChange:w=>Y(w.target.value),placeholder:"Buscar categoría...",className:"w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500"})]})}),l.jsx("div",{className:"overflow-y-auto flex-1 p-3 space-y-2",children:_g.map(({key:w,label:$})=>{const ue=me[w].filter($e=>!de||$e.name.toLowerCase().includes(de.toLowerCase()));if(!ue.length&&!de)return null;const Se=ue.filter($e=>z[w].has($e.id)).length,Ae=je[w];return l.jsxs("div",{className:"bg-dark-900/50 rounded-lg border border-dark-700 overflow-hidden",children:[l.jsxs("div",{className:"flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-dark-700/50 select-none",onClick:()=>Te($e=>({...$e,[w]:!$e[w]})),children:[l.jsxs("div",{className:"flex items-center gap-2",children:[Ae?l.jsx(No,{size:14,className:"text-gray-500"}):l.jsx(jo,{size:14,className:"text-gray-500"}),l.jsx("span",{className:"text-sm font-medium",children:$}),l.jsxs("span",{className:"text-xs text-gray-500",children:["(",Se,"/",ue.length," sel.)"]})]}),l.jsxs("div",{className:"flex gap-2",onClick:$e=>$e.stopPropagation(),children:[l.jsx("button",{onClick:()=>$r(w,ue),className:"text-xs text-primary-400 hover:text-primary-300 px-2 py-0.5 rounded hover:bg-dark-600",children:"Todo"}),l.jsx("button",{onClick:()=>sr(w),className:"text-xs text-gray-500 hover:text-gray-300 px-2 py-0.5 rounded hover:bg-dark-600",children:"Nada"})]})]}),!Ae&&l.jsx("div",{className:"grid grid-cols-2 gap-0.5 p-2 border-t border-dark-700 max-h-64 overflow-y-auto",children:ue.length===0?l.jsx("div",{className:"col-span-2 text-center text-gray-500 text-xs py-3",children:"Sin resultados"}):ue.map($e=>l.jsxs("label",{className:"flex items-center gap-2 px-2 py-1 rounded hover:bg-dark-700 cursor-pointer text-sm",children:[l.jsx("input",{type:"checkbox",checked:z[w].has($e.id),onChange:()=>Is(w,$e.id),className:"shrink-0"}),l.jsx("span",{className:"truncate text-xs",children:$e.name})]},$e.id))})]},w)})}),l.jsxs("div",{className:"p-4 border-t border-dark-700 flex gap-2 justify-end shrink-0",children:[l.jsx("button",{onClick:()=>M(null),className:"bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm",children:"Cancelar"}),l.jsxs("button",{onClick:Ms,disabled:on===0,className:"bg-primary-500 hover:bg-primary-600 disabled:opacity-40 px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2",children:[l.jsx(Ge,{size:14}),"Importar ",on," ",on===1?"categoría":"categorías"]})]})]})]})}),D&&l.jsx("div",{className:"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4",children:l.jsxs("div",{className:"bg-dark-800 rounded-xl border border-dark-700 w-full max-w-lg flex flex-col max-h-[80vh]",children:[l.jsxs("div",{className:"p-4 border-b border-dark-700 flex items-center justify-between shrink-0",children:[l.jsxs("div",{children:[l.jsxs("h2",{className:"font-semibold",children:["Novedades — ",l.jsx("span",{className:"text-primary-400",children:D.name})]}),!ee&&l.jsx("p",{className:"text-xs text-gray-500 mt-0.5",children:b.length===0?"No hay categorías nuevas":`${b.length} categorías nuevas sin sincronizar`})]}),l.jsx("button",{onClick:()=>U(null),className:"text-gray-500 hover:text-white",children:"✕"})]}),ee?l.jsx("div",{className:"flex-1 flex items-center justify-center p-10",children:l.jsx(Ge,{size:22,className:"animate-spin text-gray-400"})}):b.length===0?l.jsx("div",{className:"flex-1 flex items-center justify-center p-10 text-gray-500 text-sm",children:"Todo está actualizado ✓"}):l.jsxs(l.Fragment,{children:[l.jsxs("div",{className:"p-3 border-b border-dark-700 shrink-0",children:[l.jsxs("div",{className:"relative",children:[l.jsx(Kt,{size:14,className:"absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"}),l.jsx("input",{value:ye,onChange:w=>Ne(w.target.value),placeholder:"Buscar...",className:"w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500"})]}),l.jsxs("div",{className:"flex gap-2 mt-2",children:[l.jsx("button",{onClick:()=>pe(new Set(Qt.map(w=>w.id+"_"+w.type))),className:"text-xs text-primary-400 hover:text-primary-300 px-2 py-0.5 rounded hover:bg-dark-600",children:"Seleccionar todo"}),l.jsx("button",{onClick:()=>pe(new Set),className:"text-xs text-gray-500 hover:text-gray-300 px-2 py-0.5 rounded hover:bg-dark-600",children:"Ninguna"}),l.jsxs("span",{className:"text-xs text-gray-600 ml-auto self-center",children:[te.size," seleccionadas"]})]})]}),l.jsx("div",{className:"overflow-y-auto flex-1 p-3 space-y-1",children:Qt.map(w=>{const $=w.id+"_"+w.type;return l.jsxs("label",{className:"flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-dark-700 cursor-pointer",children:[l.jsx("input",{type:"checkbox",checked:te.has($),onChange:()=>{pe(ue=>{const Se=new Set(ue);return Se.has($)?Se.delete($):Se.add($),Se})},className:"shrink-0"}),l.jsx("span",{className:"text-sm flex-1 truncate",children:w.name}),l.jsx("span",{className:`text-xs px-1.5 py-0.5 rounded border ${Wr[w.type]}`,children:Hr[w.type]})]},$)})}),l.jsxs("div",{className:"p-4 border-t border-dark-700 flex gap-2 justify-end shrink-0",children:[l.jsx("button",{onClick:()=>U(null),className:"bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm",children:"Cancelar"}),l.jsxs("button",{onClick:An,disabled:te.size===0,className:"bg-primary-500 hover:bg-primary-600 disabled:opacity-40 px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2",children:[l.jsx(Ge,{size:14}),"Importar ",te.size," ",te.size===1?"categoría":"categorías"]})]})]})]})}),ze&&l.jsx("div",{className:"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4",children:l.jsxs("div",{className:"bg-dark-800 rounded-xl border border-dark-700 w-full max-w-lg",children:[l.jsxs("div",{className:"p-4 border-b border-dark-700 flex items-center justify-between",children:[l.jsxs("h2",{className:"font-semibold",children:["Editar proveedor — ",l.jsx("span",{className:"text-primary-400",children:ze.name})]}),l.jsx("button",{onClick:()=>Ee(null),className:"text-gray-500 hover:text-white",children:"✕"})]}),l.jsx("div",{className:"p-4 space-y-3",children:l.jsxs("div",{className:"grid grid-cols-2 gap-3",children:[l.jsx(Jn,{label:"Nombre",value:q.name,onChange:w=>ge($=>({...$,name:w})),required:!0}),l.jsx(Jn,{label:"URL base",value:q.base_url,onChange:w=>ge($=>({...$,base_url:w})),required:!0}),l.jsx(Jn,{label:"Usuario",value:q.username,onChange:w=>ge($=>({...$,username:w})),required:!0}),l.jsxs("div",{children:[l.jsx("label",{className:"block text-xs text-gray-500 mb-1",children:"Contraseña (vacío = no cambiar)"}),l.jsx("input",{type:"password",value:q.password,onChange:w=>ge($=>({...$,password:w.target.value})),placeholder:"••••••••",className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500"})]}),l.jsxs("div",{children:[l.jsx("label",{className:"block text-xs text-gray-500 mb-1",children:"Conexiones máx."}),l.jsx("input",{type:"number",min:1,max:100,value:q.max_connections,onChange:w=>ge($=>({...$,max_connections:+w.target.value})),className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"})]}),l.jsxs("div",{children:[l.jsx("label",{className:"block text-xs text-gray-500 mb-1",children:"Auto-sincronización"}),l.jsx("select",{value:q.auto_sync_hours,onChange:w=>ge($=>({...$,auto_sync_hours:+w.target.value})),className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm",children:Yd.map(w=>l.jsx("option",{value:w.value,children:w.label},w.value))})]})]})}),l.jsxs("div",{className:"p-4 border-t border-dark-700 flex gap-2 justify-end",children:[l.jsx("button",{onClick:()=>Ee(null),className:"bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm",children:"Cancelar"}),l.jsx("button",{onClick:Fs,className:"bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium",children:"Guardar"})]})]})}),l.jsxs("div",{className:"space-y-3",children:[s.map(w=>l.jsxs("div",{className:"bg-dark-800 border border-dark-700 rounded-xl overflow-hidden",children:[l.jsxs("div",{className:"p-4 flex items-center justify-between gap-4",children:[l.jsxs("div",{className:"min-w-0 flex-1",children:[l.jsxs("div",{className:"flex items-center gap-2",children:[l.jsx("span",{className:"font-semibold",children:w.name}),w.auto_sync_hours>0&&l.jsxs("span",{className:"text-xs bg-dark-700 text-gray-400 px-1.5 py-0.5 rounded",children:["Auto ",w.auto_sync_hours,"h"]})]}),l.jsxs("div",{className:"flex items-center gap-2 flex-wrap mt-0.5",children:[l.jsxs("span",{className:"text-xs text-gray-500 truncate",children:[w.base_url," • ",w.username," • ",w.max_connections," conex."]}),(()=>{const $=Cg(w.expiry_date);return $?l.jsx("span",{className:`text-xs px-1.5 py-0.5 rounded ${$.cls}`,children:$.label}):null})()]}),w.last_sync_at&&l.jsxs("div",{className:"text-xs text-gray-600 mt-0.5",children:["Último sync: ",new Date(w.last_sync_at).toLocaleString("es-ES")]}),L[w.id]!==void 0&&l.jsx("div",{className:`text-xs mt-0.5 ${L[w.id]?"text-green-400":"text-red-400"}`,children:L[w.id]?"✓ Conexión OK":"✗ Sin conexión"}),x[w.id]&&l.jsxs("div",{className:`text-xs mt-1 ${x[w.id].startsWith("✓")?"text-green-400":x[w.id].startsWith("Error")?"text-red-400":"text-blue-400"}`,children:[m.has(w.id)&&l.jsx(Ge,{size:10,className:"inline animate-spin mr-1"}),x[w.id],j[w.id]&&m.has(w.id)&&l.jsxs("span",{className:"text-gray-500 ml-1",children:["(",Object.entries(j[w.id]).filter(([,$])=>$>0).map(([$,ue])=>`${ue} ${$}`).join(", "),")"]})]})]}),l.jsxs("div",{className:"flex items-center gap-2 shrink-0",children:[l.jsx("button",{onClick:()=>an(w.id),disabled:O===w.id,title:"Probar conexión",className:"p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-blue-400 transition-colors",children:O===w.id?l.jsx(Ge,{size:15,className:"animate-spin"}):l.jsx(fa,{size:15})}),w.last_sync_at&&l.jsxs("button",{onClick:()=>$s(w),title:"Ver novedades del proveedor",className:"relative p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-yellow-400 transition-colors",children:[l.jsx(gf,{size:15}),(we[w.id]??0)>0&&l.jsx("span",{className:"absolute -top-1 -right-1 bg-yellow-500 text-black text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center",children:we[w.id]>9?"9+":we[w.id]})]}),l.jsxs("button",{onClick:()=>Us(w),disabled:m.has(w.id),title:"Sincronizar canales",className:"flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-dark-700 hover:bg-dark-600 text-xs text-gray-300 hover:text-white transition-colors disabled:opacity-50",children:[l.jsx(Ge,{size:13,className:m.has(w.id)?"animate-spin":""}),m.has(w.id)?"Sincronizando...":"Sync canales"]}),l.jsx("button",{onClick:()=>Ir(w),title:"Editar proveedor",className:"p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-yellow-400 transition-colors",children:l.jsx(kf,{size:15})}),l.jsx("button",{onClick:()=>va(w.id),title:"Gestionar dominios de failover",className:`p-2 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors ${F.has(w.id)?"text-primary-400":"text-gray-400 hover:text-primary-400"}`,children:F.has(w.id)?l.jsx(jo,{size:15}):l.jsx(No,{size:15})}),l.jsx("button",{onClick:()=>On(w.id),className:"p-2 rounded-lg bg-dark-700 hover:bg-red-900/40 text-gray-500 hover:text-red-400 transition-colors",children:l.jsx(rn,{size:15})})]})]}),F.has(w.id)&&l.jsxs("div",{className:"border-t border-dark-700 bg-dark-900/40 p-4 space-y-2",children:[l.jsxs("div",{className:"flex items-center justify-between mb-2",children:[l.jsx("span",{className:"text-xs font-semibold text-gray-400 uppercase tracking-wider",children:"Dominios (failover)"}),l.jsxs("div",{className:"flex gap-2",children:[l.jsxs("button",{onClick:()=>wa(w.id),disabled:Fr.has(w.id),className:"flex items-center gap-1 text-xs px-2 py-1 rounded bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-blue-400 transition-colors disabled:opacity-50",children:[l.jsx(Ge,{size:11,className:Fr.has(w.id)?"animate-spin":""}),"Verificar todos"]}),l.jsxs("button",{onClick:()=>{Ce(w.id),Le({url:"",priority:10,is_active:!0})},className:"flex items-center gap-1 text-xs px-2 py-1 rounded bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-green-400 transition-colors",children:[l.jsx(tr,{size:11}),"Añadir URL"]})]})]}),(w.urls??[]).length===0&&ke!==w.id&&l.jsx("div",{className:"text-xs text-gray-600 text-center py-2",children:"No hay URLs adicionales configuradas"}),(w.urls??[]).map($=>l.jsxs("div",{className:"flex items-center gap-3 bg-dark-800 rounded-lg px-3 py-2",children:[l.jsx("div",{className:"flex-1 min-w-0",children:l.jsxs("div",{className:"flex items-center gap-2",children:[l.jsx("span",{className:"text-xs font-mono text-gray-300 truncate",children:$.url}),$.priority===0&&l.jsx("span",{className:"text-[10px] bg-primary-900/40 text-primary-400 px-1 rounded",children:"primaria"}),!$.is_active&&l.jsx("span",{className:"text-[10px] bg-dark-600 text-gray-500 px-1 rounded",children:"inactiva"})]})}),l.jsx("div",{className:"shrink-0",children:Br($)}),l.jsx("button",{onClick:()=>Vs(w.id,$.id),className:"p-1 rounded text-gray-600 hover:text-red-400 transition-colors",children:l.jsx(rn,{size:12})})]},$.id)),ke===w.id&&l.jsxs("div",{className:"bg-dark-800 rounded-lg p-3 space-y-2 border border-dark-600",children:[l.jsxs("div",{className:"grid grid-cols-3 gap-2",children:[l.jsxs("div",{className:"col-span-2",children:[l.jsx("label",{className:"block text-[10px] text-gray-500 mb-1",children:"URL base (ej: http://mirror.com:8080)"}),l.jsx("input",{value:De.url,onChange:$=>Le(ue=>({...ue,url:$.target.value})),placeholder:"http://...",className:"w-full bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-xs focus:outline-none focus:border-primary-500"})]}),l.jsxs("div",{children:[l.jsx("label",{className:"block text-[10px] text-gray-500 mb-1",children:"Prioridad (0=más alta)"}),l.jsx("input",{type:"number",min:0,max:999,value:De.priority,onChange:$=>Le(ue=>({...ue,priority:+$.target.value})),className:"w-full bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-xs"})]})]}),l.jsxs("div",{className:"flex items-center gap-3",children:[l.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer",children:[l.jsx("input",{type:"checkbox",checked:De.is_active,onChange:$=>Le(ue=>({...ue,is_active:$.target.checked}))}),"Activa"]}),l.jsxs("div",{className:"flex gap-2 ml-auto",children:[l.jsx("button",{onClick:()=>Ce(null),className:"text-xs px-2 py-1 rounded bg-dark-700 hover:bg-dark-600 text-gray-400",children:"Cancelar"}),l.jsx("button",{onClick:()=>Bs(w.id),disabled:!De.url,className:"text-xs px-2 py-1 rounded bg-primary-500 hover:bg-primary-600 disabled:opacity-40 font-medium",children:"Guardar"})]})]})]})]})]},w.id)),s.length===0&&l.jsx("div",{className:"bg-dark-800 border border-dark-700 border-dashed rounded-xl p-10 text-center text-gray-500 text-sm",children:"No hay proveedores. Añade uno para empezar."})]})]})}function Jn({label:s,value:a,onChange:i,required:c}){return l.jsxs("div",{children:[l.jsx("label",{className:"block text-xs text-gray-500 mb-1",children:s}),l.jsx("input",{value:a,onChange:d=>i(d.target.value),required:c,className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500"})]})}const Pg={live:"Canales en directo",movie:"Películas",series:"Series"};function Rg(){const[s,a]=v.useState([]),[i,c]=v.useState(!1),[d,f]=v.useState(null),[m,h]=v.useState(null),[x,y]=v.useState(null),[j,_]=v.useState([]),[O,B]=v.useState(new Set),[L,P]=v.useState([]),[N,A]=v.useState(new Set),[M,K]=v.useState(""),[Q,me]=v.useState([]),[fe,z]=v.useState([]),[ae,de]=v.useState({username:"",password:"",max_connections:1,expiry_date:"",is_active:!0,is_priority:!1});async function Y(){const{data:F}=await X.get("/users/");a(F)}v.useEffect(()=>{Y()},[]);async function je(F){F.preventDefault();const ne={...ae,expiry_date:ae.expiry_date||null};d?await X.put(`/users/${d.id}`,ne):await X.post("/users/",ne),Te(),Y()}function Te(){de({username:"",password:"",max_connections:1,expiry_date:"",is_active:!0,is_priority:!1}),c(!1),f(null)}function ze(F){var ne;de({username:F.username,password:"",max_connections:F.max_connections,expiry_date:((ne=F.expiry_date)==null?void 0:ne.split("T")[0])||"",is_active:F.is_active,is_priority:F.is_priority}),f(F),c(!0)}async function Ee(F){confirm("¿Eliminar usuario?")&&(await X.delete(`/users/${F}`),Y())}async function q(F){await X.put(`/users/${F.id}`,{is_priority:!F.is_priority}),Y()}async function ge(F){h(F),K("");const[ne,ke,Ce,De]=await Promise.all([X.get("/catalogs/grouped").then(Le=>Le.data),X.get(`/users/${F.id}/catalog`).then(Le=>Le.data),X.get("/custom-categories/").then(Le=>Le.data),X.get(`/users/${F.id}/custom-catalog`).then(Le=>Le.data)]);_(ne),B(new Set(ke.map(Le=>Le.category_id))),P(Ce),A(new Set(De))}function D(F){return F.category_ids.some(ne=>O.has(ne))}function U(F,ne){B(ke=>{const Ce=new Set(ke);return ne?F.category_ids.forEach(De=>Ce.add(De)):F.category_ids.forEach(De=>Ce.delete(De)),Ce})}function ee(F){B(ke=>{const Ce=new Set(ke);return F.forEach(De=>De.category_ids.forEach(Le=>Ce.add(Le))),Ce});const ne=L.filter(ke=>!M||ke.name.toLowerCase().includes(M.toLowerCase()));A(ke=>{const Ce=new Set(ke);return ne.forEach(De=>Ce.add(De.id)),Ce})}function G(F){B(ke=>{const Ce=new Set(ke);return F.forEach(De=>De.category_ids.forEach(Le=>Ce.delete(Le))),Ce});const ne=L.filter(ke=>!M||ke.name.toLowerCase().includes(M.toLowerCase()));A(ke=>{const Ce=new Set(ke);return ne.forEach(De=>Ce.delete(De.id)),Ce})}async function b(){m&&(await Promise.all([X.post(`/users/${m.id}/catalog`,Array.from(O)),X.post(`/users/${m.id}/custom-catalog`,Array.from(N))]),h(null))}async function I(F){y(F);const[ne,ke]=await Promise.all([X.get("/providers/").then(Ce=>Ce.data),X.get(`/users/${F.id}/providers`).then(Ce=>Ce.data)]);me(ne),z(ke)}async function te(F){if(!x)return;await X.post(`/users/${x.id}/providers`,{provider_account_id:F,priority:fe.length});const{data:ne}=await X.get(`/users/${x.id}/providers`);z(ne)}async function pe(F){if(!x)return;await X.delete(`/users/${x.id}/providers/${F}`);const{data:ne}=await X.get(`/users/${x.id}/providers`);z(ne)}const ye=new Set(fe.map(F=>F.provider_account_id)),Ne=j.filter(F=>!M||F.name.toLowerCase().includes(M.toLowerCase())),we=L.filter(F=>!M||F.name.toLowerCase().includes(M.toLowerCase())),oe=j.filter(F=>D(F)).length+N.size;return l.jsxs("div",{className:"p-6 space-y-5",children:[l.jsxs("div",{className:"flex items-center justify-between",children:[l.jsx("h1",{className:"text-xl font-bold",children:"Usuarios"}),l.jsxs("button",{onClick:()=>c(F=>!F),className:"flex items-center gap-2 bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium transition-colors",children:[l.jsx(tr,{size:16})," Nuevo usuario"]})]}),i&&l.jsxs("form",{onSubmit:je,className:"bg-dark-800 border border-dark-700 rounded-xl p-5 space-y-3",children:[l.jsx("h3",{className:"font-semibold text-sm",children:d?"Editar usuario":"Nuevo usuario"}),l.jsxs("div",{className:"grid grid-cols-2 gap-3",children:[l.jsxs("div",{children:[l.jsx("label",{className:"block text-xs text-gray-500 mb-1",children:"Usuario"}),l.jsx("input",{value:ae.username,onChange:F=>de(ne=>({...ne,username:F.target.value})),required:!0,disabled:!!d,className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"})]}),l.jsxs("div",{children:[l.jsxs("label",{className:"block text-xs text-gray-500 mb-1",children:["Contraseña ",d&&"(dejar vacío para no cambiar)"]}),l.jsx("input",{type:"password",value:ae.password,onChange:F=>de(ne=>({...ne,password:F.target.value})),required:!d,className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"})]}),l.jsxs("div",{children:[l.jsx("label",{className:"block text-xs text-gray-500 mb-1",children:"Max. conexiones simultáneas"}),l.jsx("input",{type:"number",min:1,value:ae.max_connections,onChange:F=>de(ne=>({...ne,max_connections:+F.target.value})),className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"})]}),l.jsxs("div",{children:[l.jsx("label",{className:"block text-xs text-gray-500 mb-1",children:"Fecha expiración"}),l.jsx("input",{type:"date",value:ae.expiry_date,onChange:F=>de(ne=>({...ne,expiry_date:F.target.value})),className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"})]}),l.jsxs("div",{className:"flex items-center gap-4 col-span-2",children:[l.jsxs("label",{className:"flex items-center gap-2 text-sm cursor-pointer",children:[l.jsx("input",{type:"checkbox",checked:ae.is_active,onChange:F=>de(ne=>({...ne,is_active:F.target.checked}))}),"Activo"]}),l.jsxs("label",{className:"flex items-center gap-2 text-sm cursor-pointer",children:[l.jsx("input",{type:"checkbox",checked:ae.is_priority,onChange:F=>de(ne=>({...ne,is_priority:F.target.checked}))}),l.jsx(Pn,{size:13,className:"text-yellow-400"}),"Usuario prioritario"]})]})]}),l.jsxs("div",{className:"flex gap-2",children:[l.jsx("button",{type:"submit",className:"bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium",children:"Guardar"}),l.jsx("button",{type:"button",onClick:Te,className:"bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm",children:"Cancelar"})]})]}),m&&l.jsx("div",{className:"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4",children:l.jsxs("div",{className:"bg-dark-800 rounded-xl border border-dark-700 w-full max-w-2xl max-h-[85vh] flex flex-col",children:[l.jsxs("div",{className:"p-4 border-b border-dark-700 flex items-center justify-between shrink-0",children:[l.jsxs("div",{children:[l.jsxs("h2",{className:"font-semibold",children:["Catálogo de ",l.jsx("span",{className:"text-primary-400",children:m.username})]}),l.jsxs("p",{className:"text-xs text-gray-500 mt-0.5",children:[oe," de ",j.length+L.length," categorías seleccionadas"]})]}),l.jsx("button",{onClick:()=>h(null),className:"text-gray-500 hover:text-white",children:"✕"})]}),l.jsxs("div",{className:"p-3 border-b border-dark-700 shrink-0 space-y-2",children:[l.jsxs("div",{className:"relative",children:[l.jsx(Kt,{size:14,className:"absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"}),l.jsx("input",{value:M,onChange:F=>K(F.target.value),placeholder:"Buscar categoría...",className:"w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500"})]}),l.jsxs("div",{className:"flex gap-2",children:[l.jsx("button",{onClick:()=>ee(Ne),className:"text-xs px-3 py-1 rounded-lg bg-primary-900/40 hover:bg-primary-900/70 text-primary-300 border border-primary-800 transition-colors",children:"Seleccionar todo"}),l.jsx("button",{onClick:()=>G(Ne),className:"text-xs px-3 py-1 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 border border-dark-600 transition-colors",children:"Ninguna"})]})]}),l.jsxs("div",{className:"overflow-y-auto flex-1 p-4 space-y-4",children:[["live","movie","series"].map(F=>{const ne=Ne.filter(ke=>ke.type===F);return ne.length?l.jsxs("div",{children:[l.jsxs("h3",{className:"text-xs text-gray-500 uppercase tracking-wider mb-2 flex items-center justify-between",children:[l.jsx("span",{children:Pg[F]}),l.jsxs("span",{className:"normal-case text-gray-600",children:[ne.filter(ke=>D(ke)).length,"/",ne.length]})]}),l.jsx("div",{className:"grid grid-cols-2 gap-1",children:ne.map(ke=>l.jsxs("label",{className:`flex items-center gap-2 text-sm p-2 rounded-lg cursor-pointer transition-colors ${D(ke)?"bg-primary-900/20 hover:bg-primary-900/30":"hover:bg-dark-700"}`,children:[l.jsx("input",{type:"checkbox",checked:D(ke),onChange:Ce=>U(ke,Ce.target.checked),className:"shrink-0"}),l.jsx("span",{className:"truncate",children:ke.name}),ke.provider_count>0&&l.jsxs("span",{className:`shrink-0 text-xs px-1.5 py-0.5 rounded ml-auto font-medium ${ke.provider_count>1?"bg-green-900/50 text-green-400":"bg-dark-600 text-gray-500"}`,children:[ke.provider_count,"p"]})]},ke.name))})]},F):null}),Ne.length===0&&we.length===0&&l.jsx("div",{className:"text-center text-gray-500 text-sm py-8",children:"Sin resultados"}),we.length>0&&l.jsxs("div",{children:[l.jsxs("h3",{className:"text-xs text-gray-500 uppercase tracking-wider mb-2 flex items-center gap-2",children:[l.jsx(Pn,{size:11,className:"text-yellow-400"}),l.jsx("span",{children:"Categorías personalizadas"}),l.jsxs("span",{className:"normal-case text-gray-600",children:[we.filter(F=>N.has(F.id)).length,"/",we.length]})]}),l.jsx("div",{className:"grid grid-cols-2 gap-1",children:we.map(F=>l.jsxs("label",{className:`flex items-center gap-2 text-sm p-2 rounded-lg cursor-pointer transition-colors ${N.has(F.id)?"bg-yellow-900/20 hover:bg-yellow-900/30":"hover:bg-dark-700"}`,children:[l.jsx("input",{type:"checkbox",checked:N.has(F.id),onChange:ne=>{A(ke=>{const Ce=new Set(ke);return ne.target.checked?Ce.add(F.id):Ce.delete(F.id),Ce})},className:"shrink-0"}),l.jsx("span",{className:"truncate",children:F.name}),l.jsx(Pn,{size:10,className:"shrink-0 text-yellow-500 ml-auto"})]},F.id))})]})]}),l.jsxs("div",{className:"p-4 border-t border-dark-700 flex gap-2 justify-end shrink-0",children:[l.jsx("button",{onClick:()=>h(null),className:"bg-dark-700 px-4 py-2 rounded-lg text-sm",children:"Cancelar"}),l.jsx("button",{onClick:b,className:"bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium",children:"Guardar catálogo"})]})]})}),x&&l.jsx("div",{className:"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4",children:l.jsxs("div",{className:"bg-dark-800 rounded-xl border border-dark-700 w-full max-w-md flex flex-col",children:[l.jsxs("div",{className:"p-4 border-b border-dark-700 flex items-center justify-between",children:[l.jsxs("h2",{className:"font-semibold",children:["Proveedores preferidos — ",l.jsx("span",{className:"text-primary-400",children:x.username})]}),l.jsx("button",{onClick:()=>y(null),className:"text-gray-500 hover:text-white",children:"✕"})]}),l.jsxs("div",{className:"p-4 space-y-2",children:[l.jsx("p",{className:"text-xs text-gray-500 mb-3",children:"Los proveedores marcados se intentan primero al asignar un slot. Si ninguno está disponible, se usa cualquiera con slot libre."}),Q.map(F=>{const ne=ye.has(F.id);return l.jsxs("div",{className:`flex items-center justify-between p-3 rounded-lg border ${ne?"border-primary-700 bg-primary-900/20":"border-dark-600 bg-dark-700"}`,children:[l.jsx("span",{className:"text-sm",children:F.name}),l.jsx("button",{onClick:()=>ne?pe(F.id):te(F.id),className:`text-xs px-3 py-1 rounded-lg font-medium transition-colors ${ne?"bg-primary-600 hover:bg-red-700 text-white":"bg-dark-600 hover:bg-primary-700 text-gray-300 hover:text-white"}`,children:ne?"Quitar":"Preferido"})]},F.id)}),Q.length===0&&l.jsx("div",{className:"text-center text-gray-500 text-sm py-4",children:"No hay proveedores configurados"})]}),l.jsx("div",{className:"p-4 border-t border-dark-700 flex justify-end",children:l.jsx("button",{onClick:()=>y(null),className:"bg-dark-700 px-4 py-2 rounded-lg text-sm",children:"Cerrar"})})]})}),l.jsx("div",{className:"bg-dark-800 border border-dark-700 rounded-xl overflow-hidden",children:l.jsxs("table",{className:"w-full text-sm",children:[l.jsx("thead",{className:"border-b border-dark-700 text-xs text-gray-500",children:l.jsxs("tr",{children:[l.jsx("th",{className:"text-left px-4 py-3",children:"Usuario"}),l.jsx("th",{className:"text-left px-4 py-3",children:"Conexiones"}),l.jsx("th",{className:"text-left px-4 py-3",children:"Expiración"}),l.jsx("th",{className:"text-left px-4 py-3",children:"Estado"}),l.jsx("th",{className:"text-left px-4 py-3",children:"Acciones"})]})}),l.jsxs("tbody",{children:[s.map(F=>l.jsxs("tr",{className:"border-b border-dark-700 hover:bg-dark-700/30",children:[l.jsx("td",{className:"px-4 py-3",children:l.jsxs("div",{className:"flex items-center gap-1.5",children:[F.is_priority&&l.jsx(Pn,{size:12,className:"text-yellow-400 shrink-0"}),l.jsx("span",{className:"font-mono",children:F.username})]})}),l.jsx("td",{className:"px-4 py-3",children:F.max_connections}),l.jsx("td",{className:"px-4 py-3 text-gray-400",children:F.expiry_date?new Date(F.expiry_date).toLocaleDateString("es-ES"):"—"}),l.jsx("td",{className:"px-4 py-3",children:l.jsx("span",{className:`text-xs px-2 py-0.5 rounded-full ${F.is_active?"bg-green-900/40 text-green-400":"bg-red-900/40 text-red-400"}`,children:F.is_active?"Activo":"Inactivo"})}),l.jsx("td",{className:"px-4 py-3",children:l.jsxs("div",{className:"flex items-center gap-1",children:[l.jsx("button",{onClick:()=>ge(F),title:"Asignar catálogo",className:"p-1.5 rounded hover:bg-dark-600 text-gray-400 hover:text-blue-400",children:l.jsx(wf,{size:14})}),l.jsx("button",{onClick:()=>I(F),title:"Proveedores preferidos",className:"p-1.5 rounded hover:bg-dark-600 text-gray-400 hover:text-purple-400",children:l.jsx(zo,{size:14})}),l.jsx("button",{onClick:()=>q(F),title:F.is_priority?"Quitar prioridad":"Hacer prioritario",className:`p-1.5 rounded hover:bg-dark-600 transition-colors ${F.is_priority?"text-yellow-400":"text-gray-400 hover:text-yellow-400"}`,children:l.jsx(Pn,{size:14})}),l.jsx("button",{onClick:()=>ze(F),title:"Editar",className:"p-1.5 rounded hover:bg-dark-600 text-gray-400 hover:text-yellow-400",children:l.jsx(kf,{size:14})}),l.jsx("button",{onClick:()=>Ee(F.id),title:"Eliminar",className:"p-1.5 rounded hover:bg-dark-600 text-gray-400 hover:text-red-400",children:l.jsx(rn,{size:14})})]})})]},F.id)),s.length===0&&l.jsx("tr",{children:l.jsx("td",{colSpan:5,className:"px-4 py-8 text-center text-gray-500",children:"No hay usuarios"})})]})]})})]})}const Tg={live:"Directo",movie:"Películas",series:"Series"},Lg={live:"bg-blue-900/40 text-blue-300",movie:"bg-purple-900/40 text-purple-300",series:"bg-green-900/40 text-green-300"};function Og(){const[s,a]=v.useState([]),[i,c]=v.useState(""),[d,f]=v.useState("");v.useEffect(()=>{X.get("/catalogs/grouped").then(h=>a(h.data))},[]);const m=s.filter(h=>{const x=!d||h.type===d,y=!i||h.name.toLowerCase().includes(i.toLowerCase());return x&&y});return l.jsxs("div",{className:"p-6 space-y-5",children:[l.jsx("h1",{className:"text-xl font-bold",children:"Catálogos de Categorías"}),l.jsx("p",{className:"text-sm text-gray-400",children:"Categorías importadas de los proveedores. El badge indica cuántos proveedores cubren cada categoría (= streams simultáneos posibles). Asígnalas a usuarios desde la sección Usuarios."}),l.jsxs("div",{className:"flex gap-3",children:[l.jsxs("div",{className:"relative flex-1 max-w-sm",children:[l.jsx(Kt,{size:14,className:"absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"}),l.jsx("input",{value:i,onChange:h=>c(h.target.value),placeholder:"Buscar categoría...",className:"w-full bg-dark-800 border border-dark-700 rounded-lg pl-8 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500"})]}),l.jsxs("select",{value:d,onChange:h=>f(h.target.value),className:"bg-dark-800 border border-dark-700 rounded-lg px-3 py-2 text-sm focus:outline-none",children:[l.jsx("option",{value:"",children:"Todos los tipos"}),l.jsx("option",{value:"live",children:"Directo"}),l.jsx("option",{value:"movie",children:"Películas"}),l.jsx("option",{value:"series",children:"Series"})]})]}),l.jsxs("div",{className:"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3",children:[m.map(h=>l.jsxs("div",{className:"bg-dark-800 border border-dark-700 rounded-xl p-3 flex items-center gap-2",children:[l.jsx("span",{className:"text-sm truncate flex-1 min-w-0",title:h.name,children:h.name}),l.jsxs("div",{className:"flex items-center gap-1.5 shrink-0",children:[h.provider_count>0&&l.jsxs("span",{title:`${h.provider_count} proveedor${h.provider_count!==1?"es":""} — ${h.provider_count} stream${h.provider_count!==1?"s":""} simultáneo${h.provider_count!==1?"s":""} posible${h.provider_count!==1?"s":""}`,className:`text-xs px-1.5 py-0.5 rounded font-medium ${h.provider_count>1?"bg-green-900/50 text-green-400":"bg-dark-600 text-gray-500"}`,children:[h.provider_count,"p"]}),l.jsx("span",{className:`text-xs px-2 py-0.5 rounded-full ${Lg[h.type]}`,children:Tg[h.type]})]})]},h.name+h.type)),m.length===0&&l.jsx("div",{className:"col-span-full bg-dark-800 border border-dark-700 border-dashed rounded-xl p-8 text-center text-gray-500 text-sm",children:s.length===0?"Sincroniza un proveedor para ver las categorías":"No se encontraron categorías"})]}),l.jsxs("p",{className:"text-xs text-gray-600",children:[m.length," categorías mostradas de ",s.length," totales"]})]})}function zg(){const[s,a]=v.useState([]),[i,c]=v.useState(!1),[d,f]=v.useState({name:"",url:""}),[m,h]=v.useState(!1);async function x(){const{data:O}=await X.get("/epg/");a(O)}v.useEffect(()=>{x()},[]);async function y(O){O.preventDefault(),await X.post("/epg/",d),f({name:"",url:""}),c(!1),x()}async function j(O){confirm("¿Eliminar fuente EPG?")&&(await X.delete(`/epg/${O}`),x())}async function _(){h(!0);try{await X.post("/epg/refresh"),alert("EPG actualización iniciada en segundo plano")}finally{h(!1)}}return l.jsxs("div",{className:"p-6 space-y-5",children:[l.jsxs("div",{className:"flex items-center justify-between",children:[l.jsx("h1",{className:"text-xl font-bold",children:"Gestión EPG"}),l.jsxs("div",{className:"flex gap-2",children:[l.jsxs("button",{onClick:_,disabled:m,className:"flex items-center gap-2 bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm font-medium transition-colors",children:[l.jsx(Ge,{size:15,className:m?"animate-spin":""}),"Actualizar EPG"]}),l.jsxs("button",{onClick:()=>c(O=>!O),className:"flex items-center gap-2 bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium",children:[l.jsx(tr,{size:16})," Añadir fuente"]})]})]}),l.jsxs("div",{className:"bg-dark-800 border border-primary-500/20 rounded-xl p-4 text-sm text-gray-400",children:[l.jsx("strong",{className:"text-gray-200",children:"Endpoint EPG para tus apps IPTV:"}),l.jsx("div",{className:"font-mono text-xs mt-1 bg-dark-700 rounded p-2 text-green-400",children:"http://10.10.10.234/xmltv.php?username=USUARIO&password=CONTRASEÑA"})]}),i&&l.jsxs("form",{onSubmit:y,className:"bg-dark-800 border border-dark-700 rounded-xl p-5 space-y-3",children:[l.jsx("h3",{className:"font-semibold text-sm",children:"Nueva fuente XMLTV"}),l.jsxs("div",{className:"grid grid-cols-2 gap-3",children:[l.jsxs("div",{children:[l.jsx("label",{className:"block text-xs text-gray-500 mb-1",children:"Nombre"}),l.jsx("input",{value:d.name,onChange:O=>f(B=>({...B,name:O.target.value})),required:!0,className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"})]}),l.jsxs("div",{children:[l.jsx("label",{className:"block text-xs text-gray-500 mb-1",children:"URL XMLTV (o del proveedor)"}),l.jsx("input",{value:d.url,onChange:O=>f(B=>({...B,url:O.target.value})),required:!0,placeholder:"http://proveedor.com/xmltv.php?username=X&password=Y",className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"})]})]}),l.jsxs("div",{className:"flex gap-2",children:[l.jsx("button",{type:"submit",className:"bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium",children:"Guardar"}),l.jsx("button",{type:"button",onClick:()=>c(!1),className:"bg-dark-700 px-4 py-2 rounded-lg text-sm",children:"Cancelar"})]})]}),l.jsxs("div",{className:"space-y-3",children:[s.map(O=>l.jsxs("div",{className:"bg-dark-800 border border-dark-700 rounded-xl p-4 flex items-center justify-between gap-4",children:[l.jsxs("div",{children:[l.jsx("div",{className:"font-semibold",children:O.name}),l.jsx("div",{className:"text-xs text-gray-500 truncate max-w-xl",children:O.url}),O.last_fetched_at&&l.jsxs("div",{className:"text-xs text-gray-600 mt-0.5",children:["Última actualización: ",new Date(O.last_fetched_at).toLocaleString("es-ES")]})]}),l.jsx("button",{onClick:()=>j(O.id),className:"p-2 rounded-lg bg-dark-700 hover:bg-red-900/40 text-gray-500 hover:text-red-400 transition-colors",children:l.jsx(rn,{size:15})})]},O.id)),s.length===0&&l.jsx("div",{className:"bg-dark-800 border border-dark-700 border-dashed rounded-xl p-8 text-center text-gray-500 text-sm",children:"No hay fuentes EPG configuradas"})]})]})}const ho={live:"Directo",movie:"Película",series:"Serie"},Zd={live:Tr,movie:Gn,series:yf},ea={live:"bg-blue-500/20 text-blue-400",movie:"bg-purple-500/20 text-purple-400",series:"bg-orange-500/20 text-orange-400"};function Ag(){const[s,a]=v.useState([]),[i,c]=v.useState(null),[d,f]=v.useState([]),[m,h]=v.useState(""),[x,y]=v.useState(""),[j,_]=v.useState([]),[O,B]=v.useState(!1),[L,P]=v.useState(!1),[N,A]=v.useState(""),[M,K]=v.useState("live"),[Q,me]=v.useState(!1),fe=v.useCallback(async()=>{const{data:q}=await X.get("/custom-categories");a(q)},[]),z=v.useCallback(async q=>{const{data:ge}=await X.get(`/custom-categories/${q.id}/channels`);f(ge)},[]);v.useEffect(()=>{fe()},[fe]),v.useEffect(()=>{i&&z(i)},[i,z]);const ae=q=>{c(q),B(!1),h(""),_([])},de=async()=>{N.trim()&&(await X.post("/custom-categories",{name:N.trim(),type:M}),A(""),P(!1),await fe())},Y=async q=>{confirm(`¿Eliminar categoría "${q.name}"?`)&&(await X.delete(`/custom-categories/${q.id}`),(i==null?void 0:i.id)===q.id&&c(null),await fe())},je=async q=>{await X.patch(`/custom-categories/${q.id}`,{is_visible:!q.is_visible}),await fe(),(i==null?void 0:i.id)===q.id&&c({...q,is_visible:!q.is_visible})},Te=v.useCallback(async(q,ge)=>{if(!q&&!ge){_([]);return}const{data:D}=await X.get("/custom-categories/channels/search",{params:{q,type:ge}});_(D)},[]);v.useEffect(()=>{const q=setTimeout(()=>Te(m,x),300);return()=>clearTimeout(q)},[m,x,Te]);const ze=async q=>{var ge;if(i)try{await X.post(`/custom-categories/${i.id}/channels`,{channel_id:q.id}),await z(i),await fe()}catch(D){((ge=D.response)==null?void 0:ge.status)!==409&&alert("Error al añadir canal")}},Ee=async q=>{i&&(await X.delete(`/custom-categories/${i.id}/channels/${q.item_id}`),await z(i),await fe())};return l.jsxs("div",{className:"flex h-full",children:[l.jsxs("aside",{className:"w-72 border-r border-dark-700 flex flex-col bg-dark-800",children:[l.jsxs("div",{className:"p-4 border-b border-dark-700 flex items-center justify-between",children:[l.jsxs("div",{className:"flex items-center gap-2",children:[l.jsx(Pn,{size:18,className:"text-yellow-400"}),l.jsx("h2",{className:"font-semibold text-sm",children:"Categorías personalizadas"})]}),l.jsx("button",{onClick:()=>P(!0),className:"p-1.5 rounded-lg bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 transition-colors",children:l.jsx(tr,{size:15})})]}),L&&l.jsxs("div",{className:"p-3 border-b border-dark-700 bg-dark-900 space-y-2",children:[l.jsx("input",{autoFocus:!0,value:N,onChange:q=>A(q.target.value),onKeyDown:q=>q.key==="Enter"&&de(),placeholder:"Nombre de la categoría",className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-primary-500"}),l.jsxs("select",{value:M,onChange:q=>K(q.target.value),className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none",children:[l.jsx("option",{value:"live",children:"Directo"}),l.jsx("option",{value:"movie",children:"Película"}),l.jsx("option",{value:"series",children:"Serie"})]}),l.jsxs("div",{className:"flex gap-2",children:[l.jsx("button",{onClick:de,className:"flex-1 bg-primary-500 hover:bg-primary-600 text-white text-xs py-1.5 rounded-lg transition-colors",children:"Crear"}),l.jsx("button",{onClick:()=>P(!1),className:"flex-1 bg-dark-700 hover:bg-dark-600 text-xs py-1.5 rounded-lg transition-colors",children:"Cancelar"})]})]}),l.jsxs("div",{className:"flex-1 overflow-y-auto p-2 space-y-1",children:[s.length===0&&l.jsx("p",{className:"text-xs text-gray-500 text-center py-8",children:"No hay categorías personalizadas"}),s.map(q=>{const ge=Zd[q.type];return l.jsxs("div",{onClick:()=>ae(q),className:Mt("flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer group transition-colors",(i==null?void 0:i.id)===q.id?"bg-primary-500/20":"hover:bg-dark-700"),children:[l.jsx(ge,{size:14,className:ea[q.type].split(" ")[1]}),l.jsxs("div",{className:"flex-1 min-w-0",children:[l.jsx("p",{className:Mt("text-sm font-medium truncate",(i==null?void 0:i.id)===q.id?"text-primary-300":"text-gray-200"),children:q.name}),l.jsxs("p",{className:"text-xs text-gray-500",children:[ho[q.type]," · ",q.item_count," canales"]})]}),l.jsxs("div",{className:"flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity",onClick:D=>D.stopPropagation(),children:[l.jsx("button",{onClick:()=>je(q),className:Mt("text-xs px-1.5 py-0.5 rounded",q.is_visible?"text-green-400":"text-gray-600"),title:q.is_visible?"Visible (clic para ocultar)":"Oculto (clic para mostrar)",children:q.is_visible?"●":"○"}),l.jsx("button",{onClick:()=>Y(q),className:"text-red-500 hover:text-red-400 p-0.5 rounded",children:l.jsx(rn,{size:12})})]})]},q.id)})]})]}),l.jsx("main",{className:"flex-1 flex flex-col overflow-hidden",children:i?l.jsxs(l.Fragment,{children:[l.jsxs("div",{className:"p-4 border-b border-dark-700 flex items-center justify-between bg-dark-800",children:[l.jsxs("div",{children:[l.jsxs("h2",{className:"font-semibold flex items-center gap-2",children:[(()=>{const q=Zd[i.type];return l.jsx(q,{size:16,className:ea[i.type].split(" ")[1]})})(),i.name,l.jsx("span",{className:Mt("text-xs px-2 py-0.5 rounded-full",ea[i.type]),children:ho[i.type]})]}),l.jsxs("p",{className:"text-xs text-gray-500 mt-0.5",children:[d.length," canales asignados"]})]}),l.jsxs("button",{onClick:()=>B(!O),className:"flex items-center gap-2 px-3 py-1.5 bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 rounded-lg text-sm transition-colors",children:[l.jsx(tr,{size:14})," Añadir canales"]})]}),O&&l.jsxs("div",{className:"border-b border-dark-700 bg-dark-900 p-4 space-y-3",children:[l.jsxs("div",{className:"flex gap-2",children:[l.jsxs("div",{className:"relative flex-1",children:[l.jsx(Kt,{size:14,className:"absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"}),l.jsx("input",{autoFocus:!0,value:m,onChange:q=>h(q.target.value),placeholder:"Buscar canal...",className:"w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500"})]}),l.jsxs("select",{value:x,onChange:q=>y(q.target.value),className:"bg-dark-700 border border-dark-600 rounded-lg px-3 text-sm focus:outline-none",children:[l.jsx("option",{value:"",children:"Todos los tipos"}),l.jsx("option",{value:"live",children:"Directo"}),l.jsx("option",{value:"movie",children:"Película"}),l.jsx("option",{value:"series",children:"Serie"})]}),l.jsx("button",{onClick:()=>B(!1),className:"text-gray-500 hover:text-gray-300 p-2",children:l.jsx(Ln,{size:16})})]}),l.jsxs("div",{className:"grid grid-cols-2 gap-1 max-h-48 overflow-y-auto",children:[j.map(q=>{const ge=d.some(D=>D.channel_id===q.id);return l.jsxs("button",{onClick:()=>!ge&&ze(q),disabled:ge,className:Mt("flex items-center gap-2 px-3 py-2 rounded-lg text-left text-sm transition-colors",ge?"opacity-40 cursor-not-allowed bg-dark-700":"bg-dark-700 hover:bg-dark-600 cursor-pointer"),children:[q.logo?l.jsx("img",{src:q.logo,alt:"",className:"w-6 h-6 object-contain rounded",onError:D=>D.currentTarget.style.display="none"}):l.jsx("div",{className:"w-6 h-6 bg-dark-600 rounded"}),l.jsx("span",{className:"truncate flex-1",children:q.name}),ge&&l.jsx("span",{className:"text-xs text-green-500",children:"✓"})]},q.id)}),j.length===0&&(m||x)&&l.jsx("p",{className:"col-span-2 text-xs text-gray-500 text-center py-4",children:"Sin resultados"})]})]}),l.jsx("div",{className:"flex-1 overflow-y-auto p-4",children:d.length===0?l.jsxs("div",{className:"text-center py-16 text-gray-500",children:[l.jsx("p",{children:"Esta categoría está vacía."}),l.jsx("p",{className:"text-xs mt-1",children:'Usa "Añadir canales" para añadir contenido.'})]}):l.jsx("div",{className:"grid grid-cols-1 gap-1",children:d.map((q,ge)=>l.jsxs("div",{className:"flex items-center gap-3 px-3 py-2 rounded-lg bg-dark-800 hover:bg-dark-700 group transition-colors",children:[l.jsx("span",{className:"text-xs text-gray-600 w-6 text-right",children:ge+1}),q.logo?l.jsx("img",{src:q.logo,alt:"",className:"w-8 h-8 object-contain rounded",onError:D=>D.currentTarget.style.display="none"}):l.jsx("div",{className:"w-8 h-8 bg-dark-600 rounded"}),l.jsx("span",{className:"flex-1 text-sm",children:q.name}),l.jsx("span",{className:Mt("text-xs px-1.5 py-0.5 rounded",ea[q.type]||"bg-gray-700 text-gray-400"),children:ho[q.type]||q.type}),l.jsx("button",{onClick:()=>Ee(q),className:"opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-400 p-1 transition-all",children:l.jsx(Ln,{size:14})})]},q.item_id))})})]}):l.jsx("div",{className:"flex-1 flex items-center justify-center text-gray-500",children:l.jsxs("div",{className:"text-center",children:[l.jsx(Pn,{size:40,className:"mx-auto mb-3 text-gray-700"}),l.jsx("p",{children:"Selecciona una categoría para gestionar sus canales"})]})})})]})}function xo(s){return s>=1e9?(s/1e9).toFixed(2)+" GB":s>=1e6?(s/1e6).toFixed(1)+" MB":s>=1e3?(s/1e3).toFixed(0)+" KB":s+" B"}function ef(s){return s?s<60?`${s}s`:s<3600?`${Math.floor(s/60)}m ${s%60}s`:`${Math.floor(s/3600)}h ${Math.floor(s%3600/60)}m`:"—"}function Wf(s){return s?new Date(s).toLocaleString("es-ES",{dateStyle:"short",timeStyle:"short"}):"—"}const Dg={live:"bg-blue-500/20 text-blue-400",movie:"bg-purple-500/20 text-purple-400",series:"bg-orange-500/20 text-orange-400"},Fg={live:"Directo",movie:"Película",series:"Serie"};function Xn({icon:s,label:a,value:i,color:c}){return l.jsxs("div",{className:"bg-dark-800 rounded-xl p-4 flex items-center gap-3",children:[l.jsx(s,{size:20,className:c}),l.jsxs("div",{children:[l.jsx("p",{className:"text-xs text-gray-500",children:a}),l.jsx("p",{className:"text-lg font-bold",children:i})]})]})}function Ug(){const[s,a]=v.useState([]),[i,c]=v.useState(null),[d,f]=v.useState(0),[m,h]=v.useState(1),[x,y]=v.useState(!1),[j,_]=v.useState(""),[O,B]=v.useState(""),[L,P]=v.useState(""),[N,A]=v.useState(""),[M,K]=v.useState(""),Q=50,me=v.useCallback(async()=>{try{const{data:Y}=await X.get("/logs/stats");c(Y)}catch{}},[]),fe=v.useCallback(async(Y=1)=>{y(!0);try{const je={page:Y,limit:Q};j&&(je.username=j),O&&(je.channel=O),L&&(je.content_type=L),N&&(je.date_from=N),M&&(je.date_to=M);const{data:Te}=await X.get("/logs",{params:je});a(Te.items),f(Te.total),h(Y)}finally{y(!1)}},[j,O,L,N,M]);v.useEffect(()=>{me(),fe(1)},[]);const z=()=>{me(),fe(1)},ae=async()=>{confirm("¿Eliminar todos los registros de conexión?")&&(await X.delete("/logs"),a([]),f(0),me())},de=Math.ceil(d/Q);return l.jsxs("div",{className:"space-y-6",children:[l.jsxs("div",{className:"flex justify-end gap-2",children:[l.jsxs("button",{onClick:()=>{me(),fe(m)},className:"flex items-center gap-2 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 rounded-lg text-sm transition-colors",children:[l.jsx(Ge,{size:14})," Actualizar"]}),l.jsxs("button",{onClick:ae,className:"flex items-center gap-2 px-3 py-1.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded-lg text-sm transition-colors",children:[l.jsx(rn,{size:14})," Limpiar todo"]})]}),i&&l.jsxs("div",{className:"grid grid-cols-2 lg:grid-cols-4 gap-4",children:[l.jsx(Xn,{icon:So,label:"Total sesiones",value:i.total_sessions.toLocaleString(),color:"text-primary-400"}),l.jsx(Xn,{icon:ch,label:"Datos totales",value:xo(i.total_bytes),color:"text-green-400"}),l.jsx(Xn,{icon:So,label:"Duración media",value:ef(i.avg_duration_seconds),color:"text-yellow-400"}),l.jsx(Xn,{icon:aa,label:"Usuarios únicos",value:i.unique_users.toString(),color:"text-blue-400"})]}),i&&l.jsxs("div",{className:"grid grid-cols-2 gap-4",children:[l.jsxs("div",{className:"bg-dark-800 rounded-xl p-4",children:[l.jsxs("h3",{className:"text-sm font-semibold mb-3 text-gray-300 flex items-center gap-2",children:[l.jsx(aa,{size:14})," Top usuarios"]}),l.jsxs("div",{className:"space-y-2",children:[i.top_users.map((Y,je)=>l.jsxs("div",{className:"flex items-center gap-2 text-sm",children:[l.jsx("span",{className:"text-gray-600 w-4",children:je+1}),l.jsx("span",{className:"flex-1 text-gray-200 font-medium",children:Y.username}),l.jsxs("span",{className:"text-gray-400",children:[Y.sessions," sesiones"]}),l.jsx("span",{className:"text-green-400 text-xs",children:xo(Y.bytes)})]},Y.username)),i.top_users.length===0&&l.jsx("p",{className:"text-xs text-gray-600",children:"Sin datos"})]})]}),l.jsxs("div",{className:"bg-dark-800 rounded-xl p-4",children:[l.jsxs("h3",{className:"text-sm font-semibold mb-3 text-gray-300 flex items-center gap-2",children:[l.jsx(Tr,{size:14})," Top canales/contenido"]}),l.jsxs("div",{className:"space-y-2",children:[i.top_channels.map((Y,je)=>l.jsxs("div",{className:"flex items-center gap-2 text-sm",children:[l.jsx("span",{className:"text-gray-600 w-4",children:je+1}),l.jsx("span",{className:"flex-1 text-gray-200 truncate",children:Y.channel_name}),l.jsx("span",{className:"text-gray-400",children:Y.sessions})]},Y.channel_name)),i.top_channels.length===0&&l.jsx("p",{className:"text-xs text-gray-600",children:"Sin datos"})]})]})]}),l.jsx("div",{className:"bg-dark-800 rounded-xl p-4",children:l.jsxs("div",{className:"flex flex-wrap gap-2 items-end",children:[l.jsxs("div",{className:"relative",children:[l.jsx(Kt,{size:13,className:"absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500"}),l.jsx("input",{value:j,onChange:Y=>_(Y.target.value),onKeyDown:Y=>Y.key==="Enter"&&z(),placeholder:"Usuario",className:"bg-dark-700 border border-dark-600 rounded-lg pl-8 pr-3 py-1.5 text-sm w-36 focus:outline-none focus:border-primary-500"})]}),l.jsxs("div",{className:"relative",children:[l.jsx(Kt,{size:13,className:"absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500"}),l.jsx("input",{value:O,onChange:Y=>B(Y.target.value),onKeyDown:Y=>Y.key==="Enter"&&z(),placeholder:"Canal / película",className:"bg-dark-700 border border-dark-600 rounded-lg pl-8 pr-3 py-1.5 text-sm w-48 focus:outline-none focus:border-primary-500"})]}),l.jsxs("select",{value:L,onChange:Y=>P(Y.target.value),className:"bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none",children:[l.jsx("option",{value:"",children:"Todos los tipos"}),l.jsx("option",{value:"live",children:"Directo"}),l.jsx("option",{value:"movie",children:"Película"}),l.jsx("option",{value:"series",children:"Serie"})]}),l.jsx("input",{type:"date",value:N,onChange:Y=>A(Y.target.value),className:"bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none"}),l.jsx("span",{className:"text-gray-500 text-sm",children:"→"}),l.jsx("input",{type:"date",value:M,onChange:Y=>K(Y.target.value),className:"bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none"}),l.jsxs("button",{onClick:z,className:"flex items-center gap-2 px-4 py-1.5 bg-primary-500 hover:bg-primary-600 rounded-lg text-sm transition-colors",children:[l.jsx(Kt,{size:13})," Buscar"]})]})}),l.jsxs("div",{className:"bg-dark-800 rounded-xl overflow-hidden",children:[l.jsxs("div",{className:"flex items-center justify-between px-4 py-3 border-b border-dark-700",children:[l.jsxs("span",{className:"text-sm text-gray-400",children:[d.toLocaleString()," registros"]}),de>1&&l.jsxs("div",{className:"flex items-center gap-2 text-sm",children:[l.jsx("button",{onClick:()=>fe(m-1),disabled:m===1,className:"px-2 py-1 bg-dark-700 rounded disabled:opacity-40",children:"←"}),l.jsxs("span",{className:"text-gray-400",children:[m," / ",de]}),l.jsx("button",{onClick:()=>fe(m+1),disabled:m===de,className:"px-2 py-1 bg-dark-700 rounded disabled:opacity-40",children:"→"})]})]}),l.jsx("div",{className:"overflow-x-auto",children:l.jsxs("table",{className:"w-full text-sm",children:[l.jsx("thead",{children:l.jsxs("tr",{className:"text-xs text-gray-500 border-b border-dark-700",children:[l.jsx("th",{className:"text-left px-4 py-2",children:"Usuario"}),l.jsx("th",{className:"text-left px-4 py-2",children:"IP"}),l.jsx("th",{className:"text-left px-4 py-2",children:"Canal / Contenido"}),l.jsx("th",{className:"text-left px-4 py-2",children:"Tipo"}),l.jsx("th",{className:"text-left px-4 py-2",children:"Inicio"}),l.jsx("th",{className:"text-left px-4 py-2",children:"Duración"}),l.jsx("th",{className:"text-right px-4 py-2",children:"Datos"})]})}),l.jsxs("tbody",{children:[x&&l.jsx("tr",{children:l.jsx("td",{colSpan:7,className:"text-center py-8 text-gray-500",children:"Cargando..."})}),!x&&s.length===0&&l.jsx("tr",{children:l.jsx("td",{colSpan:7,className:"text-center py-8 text-gray-500",children:"No hay registros"})}),s.map(Y=>l.jsxs("tr",{className:"border-b border-dark-700/50 hover:bg-dark-700/30 transition-colors",children:[l.jsx("td",{className:"px-4 py-2 font-medium text-gray-200",children:Y.username}),l.jsx("td",{className:"px-4 py-2 text-gray-400 font-mono text-xs",children:Y.client_ip||"—"}),l.jsx("td",{className:"px-4 py-2 text-gray-300 max-w-xs truncate",children:Y.channel_name||Y.stream_id||"—"}),l.jsx("td",{className:"px-4 py-2",children:Y.content_type?l.jsx("span",{className:Mt("text-xs px-2 py-0.5 rounded-full",Dg[Y.content_type]||"bg-gray-700 text-gray-400"),children:Fg[Y.content_type]||Y.content_type}):"—"}),l.jsx("td",{className:"px-4 py-2 text-gray-400 text-xs",children:Wf(Y.started_at)}),l.jsx("td",{className:"px-4 py-2 text-gray-400",children:ef(Y.duration_seconds)}),l.jsx("td",{className:"px-4 py-2 text-right text-green-400 text-xs",children:xo(Y.bytes_transferred)})]},Y.id))]})]})})]})]})}function Mg(){const[s,a]=v.useState([]),[i,c]=v.useState(0),[d,f]=v.useState(1),[m,h]=v.useState(!1),[x,y]=v.useState(""),[j,_]=v.useState(""),[O,B]=v.useState(""),[L,P]=v.useState(""),N=50,A=v.useCallback(async(z=1)=>{h(!0);try{const ae={page:z,limit:N};x&&(ae.channel=x),j&&(ae.event_type=j),O&&(ae.date_from=O),L&&(ae.date_to=L);const{data:de}=await X.get("/logs/events",{params:ae});a(de.items),c(de.total),f(z)}finally{h(!1)}},[x,j,O,L]);v.useEffect(()=>{A(1)},[]);const M=()=>A(1),K=async()=>{confirm("¿Eliminar todos los registros de fallos?")&&(await X.delete("/logs/events"),a([]),c(0))},Q=Math.ceil(i/N),me=s.filter(z=>z.event_type==="caida").length,fe=s.filter(z=>z.event_type==="recuperado").length;return l.jsxs("div",{className:"space-y-6",children:[l.jsxs("div",{className:"flex justify-end gap-2",children:[l.jsxs("button",{onClick:()=>A(d),className:"flex items-center gap-2 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 rounded-lg text-sm transition-colors",children:[l.jsx(Ge,{size:14})," Actualizar"]}),l.jsxs("button",{onClick:K,className:"flex items-center gap-2 px-3 py-1.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded-lg text-sm transition-colors",children:[l.jsx(rn,{size:14})," Limpiar todo"]})]}),l.jsxs("div",{className:"grid grid-cols-2 lg:grid-cols-3 gap-4",children:[l.jsx(Xn,{icon:Zn,label:"Total fallos registrados",value:i.toLocaleString(),color:"text-orange-400"}),l.jsx(Xn,{icon:Zn,label:"Caídas (en vista actual)",value:me.toString(),color:"text-red-400"}),l.jsx(Xn,{icon:Ad,label:"Recuperaciones (en vista actual)",value:fe.toString(),color:"text-emerald-400"})]}),l.jsx("div",{className:"bg-dark-800 rounded-xl p-4",children:l.jsxs("div",{className:"flex flex-wrap gap-2 items-end",children:[l.jsxs("div",{className:"relative",children:[l.jsx(Kt,{size:13,className:"absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500"}),l.jsx("input",{value:x,onChange:z=>y(z.target.value),onKeyDown:z=>z.key==="Enter"&&M(),placeholder:"Canal / proveedor",className:"bg-dark-700 border border-dark-600 rounded-lg pl-8 pr-3 py-1.5 text-sm w-48 focus:outline-none focus:border-primary-500"})]}),l.jsxs("select",{value:j,onChange:z=>_(z.target.value),className:"bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none",children:[l.jsx("option",{value:"",children:"Todos los eventos"}),l.jsx("option",{value:"caida",children:"Caídas"}),l.jsx("option",{value:"recuperado",children:"Recuperaciones"})]}),l.jsx("input",{type:"date",value:O,onChange:z=>B(z.target.value),className:"bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none"}),l.jsx("span",{className:"text-gray-500 text-sm",children:"→"}),l.jsx("input",{type:"date",value:L,onChange:z=>P(z.target.value),className:"bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none"}),l.jsxs("button",{onClick:M,className:"flex items-center gap-2 px-4 py-1.5 bg-primary-500 hover:bg-primary-600 rounded-lg text-sm transition-colors",children:[l.jsx(Kt,{size:13})," Buscar"]})]})}),l.jsxs("div",{className:"bg-dark-800 rounded-xl overflow-hidden",children:[l.jsxs("div",{className:"flex items-center justify-between px-4 py-3 border-b border-dark-700",children:[l.jsxs("span",{className:"text-sm text-gray-400",children:[i.toLocaleString()," eventos"]}),Q>1&&l.jsxs("div",{className:"flex items-center gap-2 text-sm",children:[l.jsx("button",{onClick:()=>A(d-1),disabled:d===1,className:"px-2 py-1 bg-dark-700 rounded disabled:opacity-40",children:"←"}),l.jsxs("span",{className:"text-gray-400",children:[d," / ",Q]}),l.jsx("button",{onClick:()=>A(d+1),disabled:d===Q,className:"px-2 py-1 bg-dark-700 rounded disabled:opacity-40",children:"→"})]})]}),l.jsx("div",{className:"overflow-x-auto",children:l.jsxs("table",{className:"w-full text-sm",children:[l.jsx("thead",{children:l.jsxs("tr",{className:"text-xs text-gray-500 border-b border-dark-700",children:[l.jsx("th",{className:"text-left px-4 py-2",children:"Fecha"}),l.jsx("th",{className:"text-left px-4 py-2",children:"Tipo"}),l.jsx("th",{className:"text-left px-4 py-2",children:"Canal"}),l.jsx("th",{className:"text-left px-4 py-2",children:"Proveedor"}),l.jsx("th",{className:"text-left px-4 py-2",children:"Dominio"}),l.jsx("th",{className:"text-left px-4 py-2",children:"Detalle"}),l.jsx("th",{className:"text-right px-4 py-2",children:"Intento"})]})}),l.jsxs("tbody",{children:[m&&l.jsx("tr",{children:l.jsx("td",{colSpan:7,className:"text-center py-8 text-gray-500",children:"Cargando..."})}),!m&&s.length===0&&l.jsx("tr",{children:l.jsx("td",{colSpan:7,className:"text-center py-8 text-gray-500",children:"No hay eventos registrados — los fallos aparecerán aquí automáticamente"})}),s.map(z=>l.jsxs("tr",{className:"border-b border-dark-700/50 hover:bg-dark-700/30 transition-colors",children:[l.jsx("td",{className:"px-4 py-2 text-gray-400 text-xs whitespace-nowrap",children:Wf(z.created_at)}),l.jsx("td",{className:"px-4 py-2",children:z.event_type==="caida"?l.jsxs("span",{className:"inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-red-500/20 text-red-400",children:[l.jsx(Zn,{size:10})," Caída"]}):z.event_type==="recuperado"?l.jsxs("span",{className:"inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400",children:[l.jsx(Ad,{size:10})," Recuperado"]}):l.jsx("span",{className:"text-xs text-gray-400",children:z.event_type})}),l.jsx("td",{className:"px-4 py-2 text-gray-200 max-w-[180px] truncate",title:z.channel_name??"",children:z.channel_name||`#${z.channel_id}`||"—"}),l.jsx("td",{className:"px-4 py-2 text-gray-400 text-xs",children:z.provider_name||"—"}),l.jsx("td",{className:"px-4 py-2 text-gray-400 font-mono text-xs",children:z.domain||"—"}),l.jsx("td",{className:"px-4 py-2 text-gray-500 text-xs max-w-[240px] truncate",title:z.error_message??"",children:z.error_message||"—"}),l.jsx("td",{className:"px-4 py-2 text-right text-gray-500 text-xs tabular-nums",children:z.attempt_number??"—"})]},z.id))]})]})})]})]})}function Ig(){const[s,a]=v.useState("connections");return l.jsxs("div",{className:"p-6 space-y-6",children:[l.jsx("div",{className:"flex items-center justify-between",children:l.jsx("h1",{className:"text-xl font-bold",children:"Registro"})}),l.jsxs("div",{className:"flex gap-1 border-b border-dark-700 pb-0",children:[l.jsx("button",{onClick:()=>a("connections"),className:Mt("px-4 py-2 text-sm font-medium rounded-t-lg transition-colors border-b-2 -mb-px",s==="connections"?"border-primary-500 text-primary-400 bg-dark-800":"border-transparent text-gray-500 hover:text-gray-300"),children:"Conexiones de usuarios"}),l.jsxs("button",{onClick:()=>a("events"),className:Mt("px-4 py-2 text-sm font-medium rounded-t-lg transition-colors border-b-2 -mb-px flex items-center gap-1.5",s==="events"?"border-red-500 text-red-400 bg-dark-800":"border-transparent text-gray-500 hover:text-gray-300"),children:[l.jsx(Zn,{size:13})," Fallos de stream"]})]}),s==="connections"&&l.jsx(Ug,{}),s==="events"&&l.jsx(Mg,{})]})}function $g(){const[s,a]=v.useState(!1),[i,c]=v.useState(""),[d,f]=v.useState(!1),[m,h]=v.useState(!1),x=v.useRef(null),y=async()=>{try{const L=localStorage.getItem("token");(await fetch("/api/admin/settings/blocked-image",{headers:{Authorization:`Bearer ${L}`}})).ok?(a(!0),c("/panel/blocked_image.jpg?t="+Date.now())):(a(!1),c(""))}catch{a(!1)}};v.useEffect(()=>{y()},[]);const j=async L=>{var P,N;if(!L.type.startsWith("image/")){alert("Solo se permiten imágenes (JPEG, PNG, WebP, GIF)");return}f(!0);try{const A=new FormData;A.append("file",L),await X.post("/settings/blocked-image",A,{headers:{"Content-Type":"multipart/form-data"}}),await y()}catch(A){alert("Error al subir la imagen: "+(((N=(P=A.response)==null?void 0:P.data)==null?void 0:N.detail)||A.message))}finally{f(!1)}},_=L=>{var N;const P=(N=L.target.files)==null?void 0:N[0];P&&j(P),L.target.value=""},O=L=>{L.preventDefault(),h(!1);const P=L.dataTransfer.files[0];P&&j(P)},B=async()=>{confirm("¿Eliminar la imagen de bloqueo?")&&(await X.delete("/settings/blocked-image"),a(!1),c(""))};return l.jsxs("div",{className:"p-6 max-w-2xl space-y-6",children:[l.jsx("h1",{className:"text-xl font-bold",children:"Ajustes"}),l.jsxs("div",{className:"bg-dark-800 rounded-xl p-6 space-y-4",children:[l.jsxs("div",{children:[l.jsxs("h2",{className:"font-semibold text-base flex items-center gap-2",children:[l.jsx(dh,{size:18,className:"text-primary-400"}),"Imagen de conexión bloqueada"]}),l.jsx("p",{className:"text-sm text-gray-400 mt-1",children:"Esta imagen se mostrará como canal de vídeo cuando un usuario intente reproducir en un segundo dispositivo habiendo alcanzado su límite de conexiones simultáneas."})]}),l.jsxs("div",{className:"flex items-start gap-2 text-xs text-blue-400 bg-blue-500/10 rounded-lg px-3 py-2",children:[l.jsx(fh,{size:13,className:"mt-0.5 shrink-0"}),l.jsx("span",{children:"El zapping normal (cambiar de canal en el mismo dispositivo) sigue funcionando sin interrupciones. La imagen solo aparece cuando se conecta un segundo dispositivo diferente con la misma cuenta."})]}),l.jsxs("div",{onDragOver:L=>{L.preventDefault(),h(!0)},onDragLeave:()=>h(!1),onDrop:O,onClick:()=>{var L;return!d&&((L=x.current)==null?void 0:L.click())},className:` + relative border-2 border-dashed rounded-xl cursor-pointer transition-colors + ${m?"border-primary-400 bg-primary-500/10":"border-dark-600 hover:border-dark-500"} + `,children:[l.jsx("input",{ref:x,type:"file",accept:"image/*",className:"hidden",onChange:_}),s&&i?l.jsxs("div",{className:"relative",children:[l.jsx("img",{src:i,alt:"Blocked image",className:"w-full rounded-xl object-contain max-h-72"}),l.jsx("div",{className:"absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity rounded-xl flex items-center justify-center",children:l.jsx("span",{className:"text-white text-sm font-medium",children:"Haz clic para cambiar"})})]}):l.jsxs("div",{className:"py-12 flex flex-col items-center gap-3 text-gray-500",children:[l.jsx(vh,{size:32,className:m?"text-primary-400":""}),l.jsxs("div",{className:"text-center",children:[l.jsx("p",{className:"font-medium text-sm",children:"Arrastra una imagen o haz clic para subir"}),l.jsx("p",{className:"text-xs mt-1",children:"JPEG, PNG, WebP o GIF · máx. 10 MB"})]})]}),d&&l.jsx("div",{className:"absolute inset-0 bg-dark-900/80 rounded-xl flex items-center justify-center",children:l.jsx("div",{className:"text-sm text-primary-400",children:"Subiendo..."})})]}),s&&l.jsx("div",{className:"flex justify-end",children:l.jsxs("button",{onClick:B,className:"flex items-center gap-2 px-3 py-1.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded-lg text-sm transition-colors",children:[l.jsx(rn,{size:14})," Eliminar imagen"]})})]})]})}const tf={name:"",url:"http://10.10.10.15:8096",api_key:"c339ccd3b7234c9a874bcfb70e92364a",movies_category_name:"Películas KiraTV",series_category_name:"Series KiraTV",movies_library_id:"",series_library_id:""};function Bg(){const[s,a]=v.useState([]),[i,c]=v.useState(!0),[d,f]=v.useState(!1),[m,h]=v.useState(null),[x,y]=v.useState({...tf}),[j,_]=v.useState(!1),[O,B]=v.useState(null),[L,P]=v.useState(!1),[N,A]=v.useState([]),[M,K]=v.useState(!1),[Q,me]=v.useState({}),[fe,z]=v.useState(null);v.useEffect(()=>{ae()},[]);async function ae(){c(!0);try{const{data:D}=await X.get("/jellyfin");a(D)}finally{c(!1)}}function de(){h(null),y({...tf}),B(null),A([]),f(!0)}function Y(D){h(D.id),y({name:D.name,url:D.url,api_key:D.api_key,movies_category_name:D.movies_category_name,series_category_name:D.series_category_name,movies_library_id:D.movies_library_id??"",series_library_id:D.series_library_id??""}),B(null),A([]),f(!0)}async function je(){if(!(!x.url||!x.api_key)){P(!0),B(null);try{if(m){const{data:D}=await X.post(`/jellyfin/${m}/test`);B(D)}else{const{data:D}=await X.post("/jellyfin",{name:"__test__",url:x.url,api_key:x.api_key,movies_category_name:x.movies_category_name,series_category_name:x.series_category_name}),{data:U}=await X.post(`/jellyfin/${D.id}/test`);await X.delete(`/jellyfin/${D.id}`),B(U)}}catch{B({ok:!1,error:"Error de red"})}finally{P(!1)}}}async function Te(){if(!m){await ze();return}K(!0);try{const{data:D}=await X.get(`/jellyfin/${m}/libraries`);A(D)}catch{A([])}finally{K(!1)}}async function ze(){_(!0);try{const D={...x,movies_library_id:x.movies_library_id||null,series_library_id:x.series_library_id||null};if(m)await X.put(`/jellyfin/${m}`,D);else{const{data:U}=await X.post("/jellyfin",D);h(U.id)}await ae()}finally{_(!1)}}async function Ee(D){confirm("¿Eliminar esta configuración de Jellyfin? Se borrarán todos los canales sincronizados.")&&(await X.delete(`/jellyfin/${D}`),a(U=>U.filter(ee=>ee.id!==D)),m===D&&f(!1))}async function q(D){me(U=>({...U,[D]:{status:"running",message:"Iniciando…",synced:0}})),await X.post(`/jellyfin/${D}/sync`),ge(D)}function ge(D){const U=setInterval(async()=>{try{const{data:ee}=await X.get(`/jellyfin/${D}/sync-status`);me(G=>({...G,[D]:ee})),ee.status!=="running"&&(clearInterval(U),ee.status==="done"&&ae())}catch{clearInterval(U)}},1500)}return N.filter(D=>D.type==="movies"||D.type===""),N.filter(D=>D.type==="tvshows"||D.type===""),l.jsxs("div",{className:"p-6 max-w-4xl mx-auto",children:[l.jsxs("div",{className:"flex items-center justify-between mb-6",children:[l.jsxs("div",{children:[l.jsx("h1",{className:"text-2xl font-bold",children:"Biblioteca Jellyfin"}),l.jsx("p",{className:"text-gray-400 text-sm mt-1",children:"Integra tu catálogo local de Jellyfin como VOD y Series en KiraStream"})]}),l.jsxs("button",{onClick:de,className:"flex items-center gap-2 bg-primary-600 hover:bg-primary-500 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors",children:[l.jsx(tr,{size:16})," Nueva conexión"]})]}),i?l.jsx("div",{className:"flex justify-center py-12",children:l.jsx(Xl,{size:24,className:"animate-spin text-gray-400"})}):s.length===0?l.jsxs("div",{className:"bg-dark-800 border border-dark-700 rounded-xl p-12 text-center text-gray-500",children:[l.jsx(Gn,{size:36,className:"mx-auto mb-3 opacity-30"}),l.jsx("p",{className:"text-sm",children:"No hay conexiones Jellyfin configuradas"}),l.jsx("button",{onClick:de,className:"mt-4 text-primary-400 hover:text-primary-300 text-sm",children:"+ Añadir primera conexión"})]}):l.jsx("div",{className:"space-y-4",children:s.map(D=>{const U=Q[D.id];return l.jsxs("div",{className:"bg-dark-800 border border-dark-700 rounded-xl overflow-hidden",children:[l.jsxs("div",{className:"flex items-center gap-4 p-4",children:[l.jsx("div",{className:"w-10 h-10 rounded-lg bg-purple-900/30 flex items-center justify-center shrink-0",children:l.jsx(Gn,{size:20,className:"text-purple-400"})}),l.jsxs("div",{className:"flex-1 min-w-0",children:[l.jsxs("div",{className:"flex items-center gap-2",children:[l.jsx("span",{className:"font-medium",children:D.name}),l.jsx("span",{className:`text-xs px-1.5 py-0.5 rounded ${D.is_active?"bg-green-900/30 text-green-400":"bg-gray-700 text-gray-400"}`,children:D.is_active?"activo":"inactivo"})]}),l.jsx("div",{className:"text-xs text-gray-500 mt-0.5 truncate",children:D.url}),l.jsxs("div",{className:"text-xs text-gray-600 mt-0.5",children:[D.item_count," elementos",D.last_sync_at&&` · Última sync: ${new Date(D.last_sync_at).toLocaleString("es-ES")}`]})]}),l.jsxs("div",{className:"flex items-center gap-2 shrink-0",children:[(U==null?void 0:U.status)==="running"?l.jsxs("span",{className:"flex items-center gap-1 text-xs text-yellow-400",children:[l.jsx(Ge,{size:12,className:"animate-spin"})," ",U.message]}):l.jsxs("button",{onClick:()=>q(D.id),className:"flex items-center gap-1.5 text-xs bg-dark-700 hover:bg-primary-600 text-gray-300 hover:text-white px-3 py-1.5 rounded-lg transition-colors",children:[l.jsx(Ge,{size:12})," Sincronizar"]}),l.jsx("button",{onClick:()=>Y(D),className:"text-gray-500 hover:text-primary-400 p-1.5 rounded transition-colors",title:"Editar",children:l.jsx(hh,{size:14})}),l.jsx("button",{onClick:()=>Ee(D.id),className:"text-gray-500 hover:text-red-400 p-1.5 rounded transition-colors",title:"Eliminar",children:l.jsx(rn,{size:14})}),l.jsx("button",{onClick:()=>z(fe===D.id?null:D.id),className:"text-gray-500 hover:text-gray-300 p-1.5 rounded transition-colors",children:fe===D.id?l.jsx(uh,{size:14}):l.jsx(jo,{size:14})})]})]}),(U==null?void 0:U.status)==="done"&&l.jsxs("div",{className:"px-4 pb-3 text-xs text-green-400 flex items-center gap-1",children:[l.jsx(oo,{size:12})," ",U.message," · ",U.synced," elementos importados"]}),(U==null?void 0:U.status)==="error"&&l.jsxs("div",{className:"px-4 pb-3 text-xs text-red-400 flex items-center gap-1",children:[l.jsx(Ln,{size:12})," Error: ",U.message]}),fe===D.id&&l.jsxs("div",{className:"px-4 pb-4 border-t border-dark-700 pt-3 grid grid-cols-2 gap-4 text-xs text-gray-400",children:[l.jsxs("div",{children:[l.jsxs("div",{className:"flex items-center gap-1 mb-1 text-purple-400 font-medium",children:[l.jsx(Gn,{size:12})," Películas"]}),l.jsxs("div",{children:["Categoría: ",l.jsx("span",{className:"text-gray-300",children:D.movies_category_name})]}),l.jsxs("div",{children:["Biblioteca: ",l.jsx("span",{className:"text-gray-300",children:D.movies_library_id||"—"})]})]}),l.jsxs("div",{children:[l.jsxs("div",{className:"flex items-center gap-1 mb-1 text-blue-400 font-medium",children:[l.jsx(Tr,{size:12})," Series"]}),l.jsxs("div",{children:["Categoría: ",l.jsx("span",{className:"text-gray-300",children:D.series_category_name})]}),l.jsxs("div",{children:["Biblioteca: ",l.jsx("span",{className:"text-gray-300",children:D.series_library_id||"—"})]})]})]})]},D.id)})}),d&&l.jsx("div",{className:"fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4",children:l.jsxs("div",{className:"bg-dark-800 border border-dark-700 rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto",children:[l.jsxs("div",{className:"flex items-center justify-between p-4 border-b border-dark-700",children:[l.jsx("h2",{className:"font-semibold",children:m?"Editar conexión":"Nueva conexión Jellyfin"}),l.jsx("button",{onClick:()=>f(!1),className:"text-gray-500 hover:text-white",children:l.jsx(Ln,{size:16})})]}),l.jsxs("div",{className:"p-4 space-y-4",children:[l.jsxs("div",{children:[l.jsx("label",{className:"text-xs text-gray-400 mb-1 block",children:"Nombre"}),l.jsx("input",{className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm",value:x.name,onChange:D=>y(U=>({...U,name:D.target.value})),placeholder:"Mi Jellyfin"})]}),l.jsxs("div",{children:[l.jsx("label",{className:"text-xs text-gray-400 mb-1 block",children:"URL del servidor"}),l.jsx("input",{className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm font-mono",value:x.url,onChange:D=>y(U=>({...U,url:D.target.value})),placeholder:"http://192.168.1.x:8096"})]}),l.jsxs("div",{children:[l.jsx("label",{className:"text-xs text-gray-400 mb-1 block",children:"API Key"}),l.jsx("input",{className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm font-mono",value:x.api_key,onChange:D=>y(U=>({...U,api_key:D.target.value})),placeholder:"Tu API key de Jellyfin"})]}),l.jsxs("div",{className:"flex items-center gap-3",children:[l.jsxs("button",{onClick:je,disabled:L||!x.url||!x.api_key,className:"flex items-center gap-1.5 bg-dark-700 hover:bg-dark-600 text-sm px-3 py-2 rounded-lg transition-colors disabled:opacity-50",children:[L?l.jsx(Xl,{size:14,className:"animate-spin"}):l.jsx(oo,{size:14}),"Probar conexión"]}),O&&l.jsx("span",{className:`text-xs flex items-center gap-1 ${O.ok?"text-green-400":"text-red-400"}`,children:O.ok?l.jsxs(l.Fragment,{children:[l.jsx(oo,{size:12})," ",O.server_name]}):l.jsxs(l.Fragment,{children:[l.jsx(Ln,{size:12})," ",O.error]})})]}),l.jsx("hr",{className:"border-dark-600"}),l.jsxs("div",{className:"flex items-center justify-between",children:[l.jsx("span",{className:"text-xs text-gray-400 font-medium uppercase tracking-wider",children:"Bibliotecas"}),l.jsxs("button",{onClick:Te,disabled:M,className:"flex items-center gap-1 text-xs text-primary-400 hover:text-primary-300 transition-colors disabled:opacity-50",children:[M?l.jsx(Xl,{size:11,className:"animate-spin"}):l.jsx(Ge,{size:11}),m?"Cargar bibliotecas":"Guardar primero para cargar"]})]}),l.jsxs("div",{className:"grid grid-cols-2 gap-3",children:[l.jsxs("div",{children:[l.jsxs("label",{className:"text-xs text-gray-400 mb-1 block flex items-center gap-1",children:[l.jsx(Gn,{size:11})," Biblioteca de Películas"]}),N.length>0?l.jsxs("select",{className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm",value:x.movies_library_id,onChange:D=>y(U=>({...U,movies_library_id:D.target.value})),children:[l.jsx("option",{value:"",children:"— ninguna —"}),N.map(D=>l.jsxs("option",{value:D.id,children:[D.name," (",D.type||"sin tipo",")"]},D.id))]}):l.jsx("input",{className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm font-mono text-xs",value:x.movies_library_id,onChange:D=>y(U=>({...U,movies_library_id:D.target.value})),placeholder:"ID de biblioteca"})]}),l.jsxs("div",{children:[l.jsxs("label",{className:"text-xs text-gray-400 mb-1 block flex items-center gap-1",children:[l.jsx(Tr,{size:11})," Biblioteca de Series"]}),N.length>0?l.jsxs("select",{className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm",value:x.series_library_id,onChange:D=>y(U=>({...U,series_library_id:D.target.value})),children:[l.jsx("option",{value:"",children:"— ninguna —"}),N.map(D=>l.jsxs("option",{value:D.id,children:[D.name," (",D.type||"sin tipo",")"]},D.id))]}):l.jsx("input",{className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm font-mono text-xs",value:x.series_library_id,onChange:D=>y(U=>({...U,series_library_id:D.target.value})),placeholder:"ID de biblioteca"})]})]}),l.jsxs("div",{className:"grid grid-cols-2 gap-3",children:[l.jsxs("div",{children:[l.jsx("label",{className:"text-xs text-gray-400 mb-1 block",children:"Nombre categoría Películas"}),l.jsx("input",{className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm",value:x.movies_category_name,onChange:D=>y(U=>({...U,movies_category_name:D.target.value}))})]}),l.jsxs("div",{children:[l.jsx("label",{className:"text-xs text-gray-400 mb-1 block",children:"Nombre categoría Series"}),l.jsx("input",{className:"w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm",value:x.series_category_name,onChange:D=>y(U=>({...U,series_category_name:D.target.value}))})]})]})]}),l.jsxs("div",{className:"flex justify-end gap-3 p-4 border-t border-dark-700",children:[l.jsx("button",{onClick:()=>f(!1),className:"px-4 py-2 text-sm text-gray-400 hover:text-white",children:"Cancelar"}),l.jsxs("button",{onClick:async()=>{await ze(),f(!1)},disabled:j||!x.name||!x.url||!x.api_key,className:"flex items-center gap-2 bg-primary-600 hover:bg-primary-500 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50",children:[j&&l.jsx(Xl,{size:14,className:"animate-spin"}),m?"Guardar cambios":"Crear"]})]})]})})]})}function Vg({children:s}){return localStorage.getItem("token")?l.jsx(l.Fragment,{children:s}):l.jsx(vo,{to:"/login",replace:!0})}function Hg(){return l.jsx(Y0,{basename:"/panel",children:l.jsxs(V0,{children:[l.jsx(yt,{path:"/login",element:l.jsx(cg,{})}),l.jsxs(yt,{path:"/",element:l.jsx(Vg,{children:l.jsx(mg,{})}),children:[l.jsx(yt,{index:!0,element:l.jsx(vo,{to:"/dashboard",replace:!0})}),l.jsx(yt,{path:"dashboard",element:l.jsx(bg,{})}),l.jsx(yt,{path:"providers",element:l.jsx(Eg,{})}),l.jsx(yt,{path:"users",element:l.jsx(Rg,{})}),l.jsx(yt,{path:"catalogs",element:l.jsx(Og,{})}),l.jsx(yt,{path:"custom-categories",element:l.jsx(Ag,{})}),l.jsx(yt,{path:"epg",element:l.jsx(zg,{})}),l.jsx(yt,{path:"logs",element:l.jsx(Ig,{})}),l.jsx(yt,{path:"settings",element:l.jsx($g,{})}),l.jsx(yt,{path:"jellyfin",element:l.jsx(Bg,{})})]}),l.jsx(yt,{path:"*",element:l.jsx(vo,{to:"/dashboard",replace:!0})})]})})}Gm.createRoot(document.getElementById("root")).render(l.jsx(rf.StrictMode,{children:l.jsx(Hg,{})})); diff --git a/backend/static/blocked_image.jpg b/backend/static/blocked_image.jpg new file mode 100644 index 0000000..6d2077b Binary files /dev/null and b/backend/static/blocked_image.jpg differ diff --git a/backend/static/index.html b/backend/static/index.html new file mode 100644 index 0000000..14caba7 --- /dev/null +++ b/backend/static/index.html @@ -0,0 +1,14 @@ + + + + + + KiraStream Admin + + + + + +
+ + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..063ab97 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + KiraStream Admin + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..0d8fb45 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3465 @@ +{ + "name": "kirastream-admin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kirastream-admin", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.9", + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", + "recharts": "^2.13.3" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.6.3", + "vite": "^6.0.5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.357", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", + "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..bd40e3e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "kirastream-admin", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", + "lucide-react": "^0.468.0", + "axios": "^1.7.9", + "recharts": "^2.13.3", + "clsx": "^2.1.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.6.3", + "vite": "^6.0.5" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f91c949 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,41 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import Login from './pages/Login' +import Layout from './components/Layout' +import Dashboard from './pages/Dashboard' +import Providers from './pages/Providers' +import Users from './pages/Users' +import Catalogs from './pages/Catalogs' +import EPG from './pages/EPG' +import CustomCategories from './pages/CustomCategories' +import Logs from './pages/Logs' +import Settings from './pages/Settings' +import Jellyfin from './pages/Jellyfin' + +function RequireAuth({ children }: { children: React.ReactNode }) { + const token = localStorage.getItem('token') + if (!token) return + return <>{children} +} + +export default function App() { + return ( + + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + ) +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..78b1187 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,238 @@ +import { Outlet, NavLink, useNavigate } from 'react-router-dom' +import { LayoutDashboard, Server, Users, List, Tv, LogOut, Wifi, Star, ScrollText, Settings, Bell, RefreshCw, X, ChevronRight, Film } from 'lucide-react' +import clsx from 'clsx' +import { useEffect, useRef, useState } from 'react' +import api from '../lib/api' + +const NAV = [ + { to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' }, + { to: '/providers', icon: Server, label: 'Proveedores' }, + { to: '/users', icon: Users, label: 'Usuarios' }, + { to: '/catalogs', icon: List, label: 'Catálogos' }, + { to: '/custom-categories', icon: Star, label: 'Cat. personalizadas' }, + { to: '/jellyfin', icon: Film, label: 'Jellyfin' }, + { to: '/epg', icon: Tv, label: 'EPG' }, + { to: '/logs', icon: ScrollText, label: 'Registros' }, + { to: '/settings', icon: Settings, label: 'Ajustes' }, +] + +interface NotificationCategory { id: string; name: string; type: string } +interface ProviderNotification { provider_id: number; provider_name: string; categories: NotificationCategory[] } + +const TYPE_LABELS: Record = { live: 'Directo', movie: 'Película', series: 'Serie' } +const TYPE_COLORS: Record = { + live: 'bg-blue-900/30 text-blue-300 border-blue-800', + movie: 'bg-purple-900/30 text-purple-300 border-purple-800', + series: 'bg-green-900/30 text-green-300 border-green-800', +} + +export default function Layout() { + const navigate = useNavigate() + const [notifOpen, setNotifOpen] = useState(false) + const [notifications, setNotifications] = useState([]) + const [notifTotal, setNotifTotal] = useState(0) + const [notifLoading, setNotifLoading] = useState(false) + const panelRef = useRef(null) + + function logout() { + localStorage.removeItem('token') + navigate('/login') + } + + async function loadNotifications() { + setNotifLoading(true) + try { + const { data } = await api.get('/providers/notifications/all') + setNotifications(data.notifications || []) + setNotifTotal(data.total || 0) + } catch { + setNotifications([]) + setNotifTotal(0) + } finally { + setNotifLoading(false) + } + } + + useEffect(() => { + // Poll notification count every 2 minutes + const check = async () => { + try { + const { data } = await api.get('/providers/notifications/all') + setNotifTotal(data.total || 0) + if (notifOpen) setNotifications(data.notifications || []) + } catch { /* ignore */ } + } + check() + const interval = setInterval(check, 120_000) + return () => clearInterval(interval) + }, [notifOpen]) + + useEffect(() => { + if (notifOpen) loadNotifications() + }, [notifOpen]) + + // Close panel when clicking outside + useEffect(() => { + const handler = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setNotifOpen(false) + } + } + if (notifOpen) document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [notifOpen]) + + async function dismiss(providerId: number, catId: string, catType: string) { + await api.post('/providers/notifications/dismiss', { + provider_id: providerId, + category_id: catId, + category_type: catType, + }) + setNotifications(prev => + prev + .map(p => + p.provider_id === providerId + ? { ...p, categories: p.categories.filter(c => !(c.id === catId && c.type === catType)) } + : p + ) + .filter(p => p.categories.length > 0) + ) + setNotifTotal(t => Math.max(0, t - 1)) + } + + async function dismissAll(providerId: number) { + await api.post(`/providers/notifications/dismiss-provider/${providerId}`) + setNotifications(prev => prev.filter(p => p.provider_id !== providerId)) + const removed = notifications.find(p => p.provider_id === providerId)?.categories.length ?? 0 + setNotifTotal(t => Math.max(0, t - removed)) + } + + return ( +
+ {/* Sidebar */} + + + {/* Content */} +
+ +
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..54cb9c2 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,12 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply bg-dark-900 text-gray-100 font-sans antialiased; + } + ::-webkit-scrollbar { width: 6px; height: 6px; } + ::-webkit-scrollbar-track { @apply bg-dark-800; } + ::-webkit-scrollbar-thumb { @apply bg-dark-600 rounded; } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..433a27b --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,30 @@ +import axios from 'axios' + +const api = axios.create({ baseURL: '/api/admin' }) + +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token') + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) + +api.interceptors.response.use( + (r) => r, + (err) => { + if (err.response?.status === 401) { + localStorage.removeItem('token') + window.location.href = '/login' + } + return Promise.reject(err) + } +) + +export default api + +export async function login(username: string, password: string): Promise { + const form = new FormData() + form.append('username', username) + form.append('password', password) + const { data } = await axios.post('/api/admin/auth/token', form) + return data.access_token +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..964aeb4 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/pages/Catalogs.tsx b/frontend/src/pages/Catalogs.tsx new file mode 100644 index 0000000..0e21125 --- /dev/null +++ b/frontend/src/pages/Catalogs.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from 'react' +import { Search } from 'lucide-react' +import api from '../lib/api' + +interface GroupedCategory { name: string; type: string; category_ids: number[]; provider_count: number } + +const TYPE_LABELS: Record = { live: 'Directo', movie: 'Películas', series: 'Series' } +const TYPE_COLORS: Record = { + live: 'bg-blue-900/40 text-blue-300', + movie: 'bg-purple-900/40 text-purple-300', + series: 'bg-green-900/40 text-green-300', +} + +export default function Catalogs() { + const [categories, setCategories] = useState([]) + const [filter, setFilter] = useState('') + const [typeFilter, setTypeFilter] = useState('') + + useEffect(() => { + api.get('/catalogs/grouped').then(r => setCategories(r.data)) + }, []) + + const filtered = categories.filter(c => { + const matchType = !typeFilter || c.type === typeFilter + const matchSearch = !filter || c.name.toLowerCase().includes(filter.toLowerCase()) + return matchType && matchSearch + }) + + return ( +
+

Catálogos de Categorías

+

+ Categorías importadas de los proveedores. El badge indica cuántos proveedores cubren cada categoría + (= streams simultáneos posibles). Asígnalas a usuarios desde la sección Usuarios. +

+ +
+
+ + setFilter(e.target.value)} + placeholder="Buscar categoría..." + className="w-full bg-dark-800 border border-dark-700 rounded-lg pl-8 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500" + /> +
+ +
+ +
+ {filtered.map(c => ( +
+ {c.name} +
+ {c.provider_count > 0 && ( + 1 ? 'bg-green-900/50 text-green-400' : 'bg-dark-600 text-gray-500'}`} + > + {c.provider_count}p + + )} + + {TYPE_LABELS[c.type]} + +
+
+ ))} + {filtered.length === 0 && ( +
+ {categories.length === 0 ? 'Sincroniza un proveedor para ver las categorías' : 'No se encontraron categorías'} +
+ )} +
+

{filtered.length} categorías mostradas de {categories.length} totales

+
+ ) +} diff --git a/frontend/src/pages/CustomCategories.tsx b/frontend/src/pages/CustomCategories.tsx new file mode 100644 index 0000000..b9e1ac2 --- /dev/null +++ b/frontend/src/pages/CustomCategories.tsx @@ -0,0 +1,337 @@ +import { useEffect, useState, useCallback } from 'react' +import api from '../lib/api' +import { Plus, Trash2, Search, X, Tv, Film, Clapperboard, Star } from 'lucide-react' +import clsx from 'clsx' + +type CatType = 'live' | 'movie' | 'series' + +interface Category { + id: number + name: string + type: CatType + is_visible: boolean + item_count: number +} + +interface CategoryChannel { + item_id: number + channel_id: number + name: string + logo: string + type: string +} + +interface SearchChannel { + id: number + name: string + logo: string + type: string +} + +const TYPE_LABELS: Record = { live: 'Directo', movie: 'Película', series: 'Serie' } +const TYPE_ICONS: Record = { live: Tv, movie: Film, series: Clapperboard } +const TYPE_COLORS: Record = { + live: 'bg-blue-500/20 text-blue-400', + movie: 'bg-purple-500/20 text-purple-400', + series: 'bg-orange-500/20 text-orange-400', +} + +export default function CustomCategories() { + const [categories, setCategories] = useState([]) + const [selected, setSelected] = useState(null) + const [channels, setChannels] = useState([]) + const [searchQ, setSearchQ] = useState('') + const [searchType, setSearchType] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [showSearch, setShowSearch] = useState(false) + const [showCreate, setShowCreate] = useState(false) + const [newName, setNewName] = useState('') + const [newType, setNewType] = useState('live') + const [loading, setLoading] = useState(false) + + const loadCategories = useCallback(async () => { + const { data } = await api.get('/custom-categories') + setCategories(data) + }, []) + + const loadChannels = useCallback(async (cat: Category) => { + const { data } = await api.get(`/custom-categories/${cat.id}/channels`) + setChannels(data) + }, []) + + useEffect(() => { loadCategories() }, [loadCategories]) + + useEffect(() => { + if (selected) loadChannels(selected) + }, [selected, loadChannels]) + + const selectCategory = (cat: Category) => { + setSelected(cat) + setShowSearch(false) + setSearchQ('') + setSearchResults([]) + } + + const createCategory = async () => { + if (!newName.trim()) return + await api.post('/custom-categories', { name: newName.trim(), type: newType }) + setNewName('') + setShowCreate(false) + await loadCategories() + } + + const deleteCategory = async (cat: Category) => { + if (!confirm(`¿Eliminar categoría "${cat.name}"?`)) return + await api.delete(`/custom-categories/${cat.id}`) + if (selected?.id === cat.id) setSelected(null) + await loadCategories() + } + + const toggleVisible = async (cat: Category) => { + await api.patch(`/custom-categories/${cat.id}`, { is_visible: !cat.is_visible }) + await loadCategories() + if (selected?.id === cat.id) setSelected({ ...cat, is_visible: !cat.is_visible }) + } + + const doSearch = useCallback(async (q: string, t: string) => { + if (!q && !t) { setSearchResults([]); return } + const { data } = await api.get('/custom-categories/channels/search', { params: { q, type: t } }) + setSearchResults(data) + }, []) + + useEffect(() => { + const id = setTimeout(() => doSearch(searchQ, searchType), 300) + return () => clearTimeout(id) + }, [searchQ, searchType, doSearch]) + + const addChannel = async (ch: SearchChannel) => { + if (!selected) return + try { + await api.post(`/custom-categories/${selected.id}/channels`, { channel_id: ch.id }) + await loadChannels(selected) + await loadCategories() + } catch (e: any) { + if (e.response?.status !== 409) alert('Error al añadir canal') + } + } + + const removeChannel = async (item: CategoryChannel) => { + if (!selected) return + await api.delete(`/custom-categories/${selected.id}/channels/${item.item_id}`) + await loadChannels(selected) + await loadCategories() + } + + return ( +
+ {/* Left: Category list */} + + + {/* Right: Channel list */} +
+ {!selected ? ( +
+
+ +

Selecciona una categoría para gestionar sus canales

+
+
+ ) : ( + <> + {/* Header */} +
+
+

+ {(() => { const Icon = TYPE_ICONS[selected.type]; return })()} + {selected.name} + {TYPE_LABELS[selected.type]} +

+

{channels.length} canales asignados

+
+ +
+ + {/* Search panel */} + {showSearch && ( +
+
+
+ + setSearchQ(e.target.value)} + placeholder="Buscar canal..." + className="w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500" + /> +
+ + +
+
+ {searchResults.map(ch => { + const already = channels.some(c => c.channel_id === ch.id) + return ( + + ) + })} + {searchResults.length === 0 && (searchQ || searchType) && ( +

Sin resultados

+ )} +
+
+ )} + + {/* Channel list */} +
+ {channels.length === 0 ? ( +
+

Esta categoría está vacía.

+

Usa "Añadir canales" para añadir contenido.

+
+ ) : ( +
+ {channels.map((ch, idx) => ( +
+ {idx + 1} + {ch.logo ? ( + (e.currentTarget.style.display = 'none')} /> + ) : ( +
+ )} + {ch.name} + + {TYPE_LABELS[ch.type as CatType] || ch.type} + + +
+ ))} +
+ )} +
+ + )} +
+
+ ) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..bab794c --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,517 @@ +import { useEffect, useRef, useState } from 'react' +import { Activity, Server, Users, Wifi, Clock, X, Film, Tv, Clapperboard, RefreshCw, AlertTriangle, Cpu, HeartPulse, ShieldCheck } from 'lucide-react' +import api from '../lib/api' + +interface StreamClient { + client_id: string + username: string +} + +interface StreamInfo { + video_codec?: string + video_profile?: string + width?: number + height?: number + fps?: number + audio_codec?: string + audio_channels?: number + audio_sample_rate?: number + bitrate_bps?: number +} + +interface StreamSession { + stream_type: 'live' | 'movie' | 'series' + channel_id: number + channel_name: string + provider_account_id: number + provider_name: string + client_count: number + clients: StreamClient[] + started_at: number + bytes_pumped: number + bps_down: number + bps_up: number + running: boolean + // stream health + status?: 'connecting' | 'ok' | 'reconnecting' | 'stopped' + reconnect_count?: number + last_data_ago?: number + stream_info?: StreamInfo + // ffmpeg process info + ffmpeg_pid?: number | null + ffmpeg_cpu_pct?: number + // A/V health (from health monitor) + av_health?: 'ok' | 'warning' | 'error' + av_desync_count?: number // cumulative ffmpeg AV-warning events + corrections?: number // auto-corrections by health monitor + buffer_pct?: number // queue fill % of slowest client (0=fast, 100=blocked) + buffer_server_bytes?: number // bytes buffered for fastest client (reconnect coverage) + buffer_server_secs?: number // estimated seconds that buffer covers + buffer_capacity?: number // total queue capacity in bytes +} + +interface SlotInfo { + id: number + name: string + max_connections: number + status: 'busy' | 'free' + channel_id: number | null +} + +function formatBytes(bytes: number) { + if (bytes < 1024) return bytes + ' B' + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB' + if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB' + return (bytes / 1073741824).toFixed(2) + ' GB' +} + +function formatBps(bps: number) { + if (bps <= 0) return '—' + if (bps < 1024) return bps.toFixed(0) + ' B/s' + if (bps < 1048576) return (bps / 1024).toFixed(0) + ' KB/s' + return (bps / 1048576).toFixed(1) + ' MB/s' +} + +function formatDuration(startTs: number) { + const secs = Math.floor(Date.now() / 1000 - startTs) + const h = Math.floor(secs / 3600) + const m = Math.floor((secs % 3600) / 60) + const s = secs % 60 + return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s` +} + +function formatResolution(info: StreamInfo) { + if (!info.width || !info.height) return null + const label = + info.width >= 3840 ? '4K' : + info.width >= 1920 ? 'FHD' : + info.width >= 1280 ? 'HD' : + info.width >= 720 ? 'SD' : 'LD' + return `${info.width}×${info.height} (${label})` +} + +function StreamInfoBadge({ info }: { info: StreamInfo }) { + if (!info || Object.keys(info).length === 0) { + return Detectando... + } + const res = formatResolution(info) + const fps = info.fps ? `${info.fps} fps` : null + const vcodec = info.video_codec ? info.video_codec.toUpperCase() : null + const acodec = info.audio_codec ? info.audio_codec.toUpperCase() : null + const br = info.bitrate_bps ? `${(info.bitrate_bps / 1_000_000).toFixed(1)} Mb/s` : null + return ( +
+ {res &&
{res}{fps ? ` · ${fps}` : ''}
} +
+ {vcodec && ( + + {vcodec} + + )} + {acodec && ( + + {acodec}{info.audio_channels ? ` ${info.audio_channels}ch` : ''} + + )} + {br && {br}} +
+
+ ) +} + +function HealthBadge({ status, reconnectCount }: { + status?: string + reconnectCount?: number +}) { + if (!status || status === 'ok') { + if (reconnectCount && reconnectCount > 0) { + return ( + + {reconnectCount} + + ) + } + return OK + } + if (status === 'connecting') { + return Conectando + } + if (status === 'reconnecting') { + return Reconectando {reconnectCount ? `(×${reconnectCount})` : ''} + } + return {status} +} + +function AVHealthBadge({ health, desyncs, corrections }: { + health?: 'ok' | 'warning' | 'error' + desyncs?: number + corrections?: number +}) { + const hasCorrections = corrections != null && corrections > 0 + const hasDesyncs = desyncs != null && desyncs > 0 + + const badge = health === 'error' + ? + A/V error + + : health === 'warning' + ? + A/V aviso + + : + A/V ok + + + return ( +
+ {badge} + {hasCorrections && ( + + ×{corrections} + + )} + {hasDesyncs && ( + + {desyncs} ev. + + )} +
+ ) +} + +function BufferBar({ pct, serverSecs, serverCapacity }: { + pct?: number + serverSecs?: number + serverCapacity?: number +}) { + const v = pct ?? 0 + const color = + v >= 70 ? 'bg-red-500' : + v >= 40 ? 'bg-yellow-400' : + 'bg-emerald-500' + const label = + v >= 70 ? 'text-red-400' : + v >= 40 ? 'text-yellow-400' : + 'text-emerald-500' + + const srvSecs = serverSecs ?? 0 + const capMB = ((serverCapacity ?? 0) / 1048576).toFixed(0) + // 0 is healthy: queue is empty because client consumes at the same rate data arrives. + // The protection kicks in during a reconnect: the queue is NOT drained (unlike before), + // so whatever was buffered at the moment of disconnect is preserved. Capacity = 32 MB. + const srvLabel = srvSecs > 0.5 ? `${srvSecs.toFixed(1)}s` : 'ok' + const srvColor = srvSecs > 10 ? 'text-yellow-400' : 'text-emerald-500' + const srvTooltip = `Buffer servidor: ${srvSecs > 0 ? srvSecs.toFixed(1) + 's de cobertura' : '0 — normal en reposo'}. Capacidad máx: ${capMB} MB. El buffer se activa durante reconexiones del proveedor.` + + return ( +
+
+ Cola cliente + {v.toFixed(0)}% +
+
+
+
+
+ Reserva ({capMB} MB) + {srvLabel} +
+
+ ) +} + +const TYPE_ICONS: Record = { + live: , + movie: , + series: , +} +const TYPE_LABELS: Record = { live: 'Directo', movie: 'Película', series: 'Serie' } +const TYPE_COLORS: Record = { + live: 'bg-blue-900/30 border-blue-800 text-blue-300', + movie: 'bg-purple-900/30 border-purple-800 text-purple-300', + series: 'bg-green-900/30 border-green-800 text-green-300', +} + +export default function Dashboard() { + const [streams, setStreams] = useState([]) + const [slots, setSlots] = useState([]) + const [connected, setConnected] = useState(false) + const [killing, setKilling] = useState(null) + const [killError, setKillError] = useState(null) + const wsRef = useRef(null) + + useEffect(() => { + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + const wsUrl = `${proto}://${window.location.host}/api/admin/monitoring/ws` + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onopen = () => setConnected(true) + ws.onclose = () => setConnected(false) + ws.onmessage = (e) => { + const msg = JSON.parse(e.data) + if (msg.type === 'state' || msg.type === 'update') { + setStreams(msg.data) + } + } + return () => ws.close() + }, []) + + useEffect(() => { + api.get('/monitoring/slots').then(r => setSlots(r.data)).catch(() => {}) + }, [streams]) + + async function killClient(clientId: string, username: string) { + if (!confirm(`¿Cortar la conexión de "${username}"?`)) return + setKilling(clientId) + setKillError(null) + try { + await api.post('/monitoring/kill', { client_id: clientId }) + } catch (err: unknown) { + const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail + setKillError(msg || 'Error al cortar la conexión') + setTimeout(() => setKillError(null), 4000) + } finally { + setKilling(null) + } + } + + const totalClients = streams.reduce((a, s) => a + s.client_count, 0) + const busySlots = slots.filter(s => s.status === 'busy').length + const totalBpsDown = streams.reduce((a, s) => a + (s.bps_down || 0), 0) + const totalCpu = streams.reduce((a, s) => a + (s.ffmpeg_cpu_pct ?? 0), 0) + const anyReconnecting = streams.some(s => s.status === 'reconnecting') + const streamsOk = streams.filter(s => !s.av_health || s.av_health === 'ok').length + const streamsWarn = streams.filter(s => s.av_health === 'warning').length + const streamsErr = streams.filter(s => s.av_health === 'error').length + const totalCorrections = streams.reduce((a, s) => a + (s.corrections ?? 0), 0) + + const healthColor = + streamsErr > 0 ? 'text-red-400' : + streamsWarn > 0 ? 'text-yellow-400' : + 'text-emerald-400' + const healthValue = + streams.length === 0 ? '—' : + streamsErr > 0 ? `${streamsErr} error${streamsErr > 1 ? 'es' : ''}` : + streamsWarn > 0 ? `${streamsWarn} aviso${streamsWarn > 1 ? 's' : ''}` : + `${streamsOk} ok` + + return ( +
+
+

Dashboard

+
+ {anyReconnecting && ( + + + Reconectando stream(s)... + + )} + {streamsErr > 0 && ( + + + {streamsErr} stream{streamsErr > 1 ? 's' : ''} con error A/V + + )} +
+ + {connected ? 'Conectado en tiempo real' : 'Desconectado'} +
+
+
+ + {killError && ( +
+ {killError} +
+ )} + + {/* Stats cards */} +
+ } label="Streams activos" value={streams.length} /> + } label="Usuarios conectados" value={totalClients} /> + } label="Slots ocupados" value={`${busySlots}/${slots.length}`} /> + } label="↓ Total proveedor" value={formatBps(totalBpsDown)} /> + 80 ? 'text-red-400' : totalCpu > 40 ? 'text-yellow-400' : 'text-cyan-400'} />} + label="ffmpeg CPU total" + value={`${totalCpu.toFixed(2)}%`} + /> + } + label={`Salud A/V${totalCorrections > 0 ? ` · ${totalCorrections} corr.` : ''}`} + value={healthValue} + valueClass={healthColor} + /> +
+ + {/* Provider slots */} + {slots.length > 0 && ( +
+

Estado de Slots del Proveedor

+
+ {slots.map(slot => ( +
+ {slot.name} + {slot.status === 'busy' ? `ch#${slot.channel_id}` : 'libre'} +
+ ))} +
+
+ )} + + {/* Active streams table */} +
+
+

Streams en Reproducción

+
+ {streams.length === 0 ? ( +
No hay streams activos
+ ) : ( +
+ + + + + + + + + + + + + + + + + {streams.map((s, idx) => ( + + + + + + + {/* Stream status + ffmpeg */} + + + {/* A/V health + buffer */} + + + + + + + + ))} + +
TipoCanalProveedorStreamEstadoSalud A/VUsuarios↓ / ↑TiempoDatos
+ + {TYPE_ICONS[s.stream_type]} + {TYPE_LABELS[s.stream_type]} + + + + {s.channel_name} + + #{s.channel_id} + + + {s.provider_name || `#${s.provider_account_id}`} + + + {s.stream_info + ? + : — + } + + + {s.ffmpeg_pid && ( +
+ + + ffmpeg + + {s.ffmpeg_cpu_pct !== undefined && ( + 80 ? 'text-red-400' : + s.ffmpeg_cpu_pct > 40 ? 'text-yellow-400' : + 'text-gray-400' + }`}> + {s.ffmpeg_cpu_pct.toFixed(2)}% + + )} +
+ )} +
+ + + +
+ {s.clients.map(c => ( +
+ {c.username} + +
+ ))} +
+
+
↓ {formatBps(s.bps_down)}
+
↑ {formatBps(s.bps_up)}
+
+ + {formatDuration(s.started_at)} + {formatBytes(s.bytes_pumped)}
+
+ )} +
+
+ ) +} + +function StatCard({ icon, label, value, valueClass }: { + icon: React.ReactNode + label: string + value: string | number + valueClass?: string +}) { + return ( +
+
{icon}
+
+

{label}

+

{value}

+
+
+ ) +} diff --git a/frontend/src/pages/EPG.tsx b/frontend/src/pages/EPG.tsx new file mode 100644 index 0000000..844227f --- /dev/null +++ b/frontend/src/pages/EPG.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'react' +import { Plus, Trash2, RefreshCw } from 'lucide-react' +import api from '../lib/api' + +interface EpgSource { id: number; name: string; url: string; is_active: boolean; last_fetched_at: string | null } + +export default function EPG() { + const [sources, setSources] = useState([]) + const [showForm, setShowForm] = useState(false) + const [form, setForm] = useState({ name: '', url: '' }) + const [refreshing, setRefreshing] = useState(false) + + async function load() { + const { data } = await api.get('/epg/') + setSources(data) + } + + useEffect(() => { load() }, []) + + async function submit(e: React.FormEvent) { + e.preventDefault() + await api.post('/epg/', form) + setForm({ name: '', url: '' }) + setShowForm(false) + load() + } + + async function del(id: number) { + if (!confirm('¿Eliminar fuente EPG?')) return + await api.delete(`/epg/${id}`) + load() + } + + async function refresh() { + setRefreshing(true) + try { + await api.post('/epg/refresh') + alert('EPG actualización iniciada en segundo plano') + } finally { + setRefreshing(false) + } + } + + return ( +
+
+

Gestión EPG

+
+ + +
+
+ +
+ Endpoint EPG para tus apps IPTV: +
+ http://10.10.10.234/xmltv.php?username=USUARIO&password=CONTRASEÑA +
+
+ + {showForm && ( +
+

Nueva fuente XMLTV

+
+
+ + setForm(f => ({ ...f, name: e.target.value }))} required + className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" /> +
+
+ + setForm(f => ({ ...f, url: e.target.value }))} required + placeholder="http://proveedor.com/xmltv.php?username=X&password=Y" + className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" /> +
+
+
+ + +
+
+ )} + +
+ {sources.map(s => ( +
+
+
{s.name}
+
{s.url}
+ {s.last_fetched_at && ( +
+ Última actualización: {new Date(s.last_fetched_at).toLocaleString('es-ES')} +
+ )} +
+ +
+ ))} + {sources.length === 0 && ( +
+ No hay fuentes EPG configuradas +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/Jellyfin.tsx b/frontend/src/pages/Jellyfin.tsx new file mode 100644 index 0000000..f03af5b --- /dev/null +++ b/frontend/src/pages/Jellyfin.tsx @@ -0,0 +1,480 @@ +import { useEffect, useState } from 'react' +import { Film, Tv, RefreshCw, Trash2, Plus, Check, X, ChevronDown, ChevronUp, Loader, Pencil } from 'lucide-react' +import api from '../lib/api' + +interface JellyfinConfig { + id: number + name: string + url: string + api_key: string + is_active: boolean + movies_library_id: string | null + series_library_id: string | null + movies_category_name: string + series_category_name: string + last_sync_at: string | null + item_count: number +} + +interface Library { + id: string + name: string + type: string +} + +interface SyncStatus { + status: 'idle' | 'running' | 'done' | 'error' + message: string + synced: number +} + +const EMPTY_FORM = { + name: '', + url: 'http://10.10.10.15:8096', + api_key: 'c339ccd3b7234c9a874bcfb70e92364a', + movies_category_name: 'Películas KiraTV', + series_category_name: 'Series KiraTV', + movies_library_id: '', + series_library_id: '', +} + +export default function Jellyfin() { + const [configs, setConfigs] = useState([]) + const [loading, setLoading] = useState(true) + const [showForm, setShowForm] = useState(false) + const [editId, setEditId] = useState(null) + const [form, setForm] = useState({ ...EMPTY_FORM }) + const [saving, setSaving] = useState(false) + const [testResult, setTestResult] = useState<{ ok: boolean; server_name?: string; error?: string } | null>(null) + const [testing, setTesting] = useState(false) + const [libraries, setLibraries] = useState([]) + const [libsLoading, setLibsLoading] = useState(false) + const [syncStatus, setSyncStatus] = useState>({}) + const [expanded, setExpanded] = useState(null) + + useEffect(() => { load() }, []) + + async function load() { + setLoading(true) + try { + const { data } = await api.get('/jellyfin') + setConfigs(data) + } finally { + setLoading(false) + } + } + + function openCreate() { + setEditId(null) + setForm({ ...EMPTY_FORM }) + setTestResult(null) + setLibraries([]) + setShowForm(true) + } + + function openEdit(cfg: JellyfinConfig) { + setEditId(cfg.id) + setForm({ + name: cfg.name, + url: cfg.url, + api_key: cfg.api_key, + movies_category_name: cfg.movies_category_name, + series_category_name: cfg.series_category_name, + movies_library_id: cfg.movies_library_id ?? '', + series_library_id: cfg.series_library_id ?? '', + }) + setTestResult(null) + setLibraries([]) + setShowForm(true) + } + + async function testConnection() { + if (!form.url || !form.api_key) return + setTesting(true) + setTestResult(null) + try { + // Test via temporary save-less call using an existing config if editId, else test inline + if (editId) { + const { data } = await api.post(`/jellyfin/${editId}/test`) + setTestResult(data) + } else { + // Create temp config, test, then delete + const { data: created } = await api.post('/jellyfin', { + name: '__test__', + url: form.url, + api_key: form.api_key, + movies_category_name: form.movies_category_name, + series_category_name: form.series_category_name, + }) + const { data: result } = await api.post(`/jellyfin/${created.id}/test`) + await api.delete(`/jellyfin/${created.id}`) + setTestResult(result) + } + } catch { + setTestResult({ ok: false, error: 'Error de red' }) + } finally { + setTesting(false) + } + } + + async function loadLibraries() { + if (!editId) { + // Need to save first + await save() + return + } + setLibsLoading(true) + try { + const { data } = await api.get(`/jellyfin/${editId}/libraries`) + setLibraries(data) + } catch { + setLibraries([]) + } finally { + setLibsLoading(false) + } + } + + async function save() { + setSaving(true) + try { + const payload = { + ...form, + movies_library_id: form.movies_library_id || null, + series_library_id: form.series_library_id || null, + } + if (editId) { + await api.put(`/jellyfin/${editId}`, payload) + } else { + const { data } = await api.post('/jellyfin', payload) + setEditId(data.id) + } + await load() + } finally { + setSaving(false) + } + } + + async function deleteCfg(id: number) { + if (!confirm('¿Eliminar esta configuración de Jellyfin? Se borrarán todos los canales sincronizados.')) return + await api.delete(`/jellyfin/${id}`) + setConfigs(prev => prev.filter(c => c.id !== id)) + if (editId === id) setShowForm(false) + } + + async function startSync(id: number) { + setSyncStatus(prev => ({ ...prev, [id]: { status: 'running', message: 'Iniciando…', synced: 0 } })) + await api.post(`/jellyfin/${id}/sync`) + pollSync(id) + } + + function pollSync(id: number) { + const iv = setInterval(async () => { + try { + const { data } = await api.get(`/jellyfin/${id}/sync-status`) + setSyncStatus(prev => ({ ...prev, [id]: data })) + if (data.status !== 'running') { + clearInterval(iv) + if (data.status === 'done') load() + } + } catch { + clearInterval(iv) + } + }, 1500) + } + + const movieLibs = libraries.filter(l => l.type === 'movies' || l.type === '') + const tvLibs = libraries.filter(l => l.type === 'tvshows' || l.type === '') + + return ( +
+
+
+

Biblioteca Jellyfin

+

+ Integra tu catálogo local de Jellyfin como VOD y Series en KiraStream +

+
+ +
+ + {/* Config list */} + {loading ? ( +
+ +
+ ) : configs.length === 0 ? ( +
+ +

No hay conexiones Jellyfin configuradas

+ +
+ ) : ( +
+ {configs.map(cfg => { + const ss = syncStatus[cfg.id] + return ( +
+
+
+ +
+
+
+ {cfg.name} + + {cfg.is_active ? 'activo' : 'inactivo'} + +
+
{cfg.url}
+
+ {cfg.item_count} elementos + {cfg.last_sync_at && ` · Última sync: ${new Date(cfg.last_sync_at).toLocaleString('es-ES')}`} +
+
+
+ {ss?.status === 'running' ? ( + + {ss.message} + + ) : ( + + )} + + + +
+
+ + {ss?.status === 'done' && ( +
+ {ss.message} · {ss.synced} elementos importados +
+ )} + {ss?.status === 'error' && ( +
+ Error: {ss.message} +
+ )} + + {expanded === cfg.id && ( +
+
+
+ Películas +
+
Categoría: {cfg.movies_category_name}
+
Biblioteca: {cfg.movies_library_id || '—'}
+
+
+
+ Series +
+
Categoría: {cfg.series_category_name}
+
Biblioteca: {cfg.series_library_id || '—'}
+
+
+ )} +
+ ) + })} +
+ )} + + {/* Form modal */} + {showForm && ( +
+
+
+

{editId ? 'Editar conexión' : 'Nueva conexión Jellyfin'}

+ +
+ +
+
+ + setForm(f => ({ ...f, name: e.target.value }))} + placeholder="Mi Jellyfin" + /> +
+
+ + setForm(f => ({ ...f, url: e.target.value }))} + placeholder="http://192.168.1.x:8096" + /> +
+
+ + setForm(f => ({ ...f, api_key: e.target.value }))} + placeholder="Tu API key de Jellyfin" + /> +
+ + {/* Test connection */} +
+ + {testResult && ( + + {testResult.ok ? ( + <> {testResult.server_name} + ) : ( + <> {testResult.error} + )} + + )} +
+ +
+ + {/* Library selectors */} +
+ Bibliotecas + +
+ +
+
+ + {libraries.length > 0 ? ( + + ) : ( + setForm(f => ({ ...f, movies_library_id: e.target.value }))} + placeholder="ID de biblioteca" + /> + )} +
+
+ + {libraries.length > 0 ? ( + + ) : ( + setForm(f => ({ ...f, series_library_id: e.target.value }))} + placeholder="ID de biblioteca" + /> + )} +
+
+ +
+
+ + setForm(f => ({ ...f, movies_category_name: e.target.value }))} + /> +
+
+ + setForm(f => ({ ...f, series_category_name: e.target.value }))} + /> +
+
+
+ +
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..48aa203 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Wifi } from 'lucide-react' +import { login } from '../lib/api' + +export default function Login() { + const [user, setUser] = useState('') + const [pass, setPass] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const navigate = useNavigate() + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + setError('') + try { + const token = await login(user, pass) + localStorage.setItem('token', token) + navigate('/dashboard') + } catch { + setError('Credenciales incorrectas') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+ +

KiraStream

+
+
+

Panel de Administración

+ {error && ( +
+ {error} +
+ )} +
+ + setUser(e.target.value)} + className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500" + autoComplete="username" + required + /> +
+
+ + setPass(e.target.value)} + className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500" + autoComplete="current-password" + required + /> +
+ +
+
+
+ ) +} diff --git a/frontend/src/pages/Logs.tsx b/frontend/src/pages/Logs.tsx new file mode 100644 index 0000000..79b0a16 --- /dev/null +++ b/frontend/src/pages/Logs.tsx @@ -0,0 +1,469 @@ +import { useEffect, useState, useCallback } from 'react' +import api from '../lib/api' +import { Search, Trash2, RefreshCw, Users, Tv, Database, Clock, AlertTriangle, CheckCircle } from 'lucide-react' +import clsx from 'clsx' + +// ─── Connection log types ─────────────────────────────────────────────────── + +interface LogEntry { + id: number + user_id: number + username: string + channel_name: string | null + content_type: string | null + stream_id: string | null + started_at: string | null + ended_at: string | null + duration_seconds: number | null + bytes_transferred: number + client_ip: string | null +} + +interface Stats { + total_sessions: number + total_bytes: number + avg_duration_seconds: number + unique_users: number + top_users: { username: string; sessions: number; bytes: number }[] + top_channels: { channel_name: string; sessions: number }[] +} + +// ─── Stream event types ────────────────────────────────────────────────────── + +interface StreamEvent { + id: number + channel_id: number | null + channel_name: string | null + provider_name: string | null + event_type: 'caida' | 'recuperado' | string + domain: string | null + error_message: string | null + attempt_number: number | null + created_at: string | null +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function fmtBytes(b: number): string { + if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB' + if (b >= 1e6) return (b / 1e6).toFixed(1) + ' MB' + if (b >= 1e3) return (b / 1e3).toFixed(0) + ' KB' + return b + ' B' +} + +function fmtDuration(s: number | null): string { + if (!s) return '—' + if (s < 60) return `${s}s` + if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s` + return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m` +} + +function fmtDate(iso: string | null): string { + if (!iso) return '—' + return new Date(iso).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' }) +} + +const TYPE_COLORS: Record = { + live: 'bg-blue-500/20 text-blue-400', + movie: 'bg-purple-500/20 text-purple-400', + series: 'bg-orange-500/20 text-orange-400', +} +const TYPE_LABELS: Record = { live: 'Directo', movie: 'Película', series: 'Serie' } + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function StatCard({ icon: Icon, label, value, color }: { icon: typeof Clock; label: string; value: string; color: string }) { + return ( +
+ +
+

{label}

+

{value}

+
+
+ ) +} + +// ─── Connection Logs tab ────────────────────────────────────────────────────── + +function ConnectionsTab() { + const [logs, setLogs] = useState([]) + const [stats, setStats] = useState(null) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(false) + + const [filterUsername, setFilterUsername] = useState('') + const [filterChannel, setFilterChannel] = useState('') + const [filterType, setFilterType] = useState('') + const [filterDateFrom, setFilterDateFrom] = useState('') + const [filterDateTo, setFilterDateTo] = useState('') + + const limit = 50 + + const loadStats = useCallback(async () => { + try { + const { data } = await api.get('/logs/stats') + setStats(data) + } catch {} + }, []) + + const loadLogs = useCallback(async (p = 1) => { + setLoading(true) + try { + const params: Record = { page: p, limit } + if (filterUsername) params.username = filterUsername + if (filterChannel) params.channel = filterChannel + if (filterType) params.content_type = filterType + if (filterDateFrom) params.date_from = filterDateFrom + if (filterDateTo) params.date_to = filterDateTo + const { data } = await api.get('/logs', { params }) + setLogs(data.items) + setTotal(data.total) + setPage(p) + } finally { + setLoading(false) + } + }, [filterUsername, filterChannel, filterType, filterDateFrom, filterDateTo]) + + useEffect(() => { loadStats(); loadLogs(1) }, []) // eslint-disable-line + + const doSearch = () => { loadStats(); loadLogs(1) } + + const clearLogs = async () => { + if (!confirm('¿Eliminar todos los registros de conexión?')) return + await api.delete('/logs') + setLogs([]) + setTotal(0) + loadStats() + } + + const totalPages = Math.ceil(total / limit) + + return ( +
+
+ + +
+ + {stats && ( +
+ + + + +
+ )} + + {stats && ( +
+
+

Top usuarios

+
+ {stats.top_users.map((u, i) => ( +
+ {i + 1} + {u.username} + {u.sessions} sesiones + {fmtBytes(u.bytes)} +
+ ))} + {stats.top_users.length === 0 &&

Sin datos

} +
+
+
+

Top canales/contenido

+
+ {stats.top_channels.map((c, i) => ( +
+ {i + 1} + {c.channel_name} + {c.sessions} +
+ ))} + {stats.top_channels.length === 0 &&

Sin datos

} +
+
+
+ )} + +
+
+
+ + setFilterUsername(e.target.value)} onKeyDown={e => e.key === 'Enter' && doSearch()} placeholder="Usuario" className="bg-dark-700 border border-dark-600 rounded-lg pl-8 pr-3 py-1.5 text-sm w-36 focus:outline-none focus:border-primary-500" /> +
+
+ + setFilterChannel(e.target.value)} onKeyDown={e => e.key === 'Enter' && doSearch()} placeholder="Canal / película" className="bg-dark-700 border border-dark-600 rounded-lg pl-8 pr-3 py-1.5 text-sm w-48 focus:outline-none focus:border-primary-500" /> +
+ + setFilterDateFrom(e.target.value)} className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none" /> + → + setFilterDateTo(e.target.value)} className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none" /> + +
+
+ +
+
+ {total.toLocaleString()} registros + {totalPages > 1 && ( +
+ + {page} / {totalPages} + +
+ )} +
+
+ + + + + + + + + + + + + + {loading && } + {!loading && logs.length === 0 && } + {logs.map(log => ( + + + + + + + + + + ))} + +
UsuarioIPCanal / ContenidoTipoInicioDuraciónDatos
Cargando...
No hay registros
{log.username}{log.client_ip || '—'}{log.channel_name || log.stream_id || '—'} + {log.content_type ? ( + + {TYPE_LABELS[log.content_type] || log.content_type} + + ) : '—'} + {fmtDate(log.started_at)}{fmtDuration(log.duration_seconds)}{fmtBytes(log.bytes_transferred)}
+
+
+
+ ) +} + +// ─── Stream Events tab ──────────────────────────────────────────────────────── + +function EventsTab() { + const [events, setEvents] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(false) + + const [filterChannel, setFilterChannel] = useState('') + const [filterType, setFilterType] = useState('') + const [filterDateFrom, setFilterDateFrom] = useState('') + const [filterDateTo, setFilterDateTo] = useState('') + + const limit = 50 + + const loadEvents = useCallback(async (p = 1) => { + setLoading(true) + try { + const params: Record = { page: p, limit } + if (filterChannel) params.channel = filterChannel + if (filterType) params.event_type = filterType + if (filterDateFrom) params.date_from = filterDateFrom + if (filterDateTo) params.date_to = filterDateTo + const { data } = await api.get('/logs/events', { params }) + setEvents(data.items) + setTotal(data.total) + setPage(p) + } finally { + setLoading(false) + } + }, [filterChannel, filterType, filterDateFrom, filterDateTo]) + + useEffect(() => { loadEvents(1) }, []) // eslint-disable-line + + const doSearch = () => loadEvents(1) + + const clearEvents = async () => { + if (!confirm('¿Eliminar todos los registros de fallos?')) return + await api.delete('/logs/events') + setEvents([]) + setTotal(0) + } + + const totalPages = Math.ceil(total / limit) + + const caidas = events.filter(e => e.event_type === 'caida').length + const recuperados = events.filter(e => e.event_type === 'recuperado').length + + return ( +
+
+ + +
+ + {/* Summary cards */} +
+ + + +
+ + {/* Filters */} +
+
+
+ + setFilterChannel(e.target.value)} onKeyDown={e => e.key === 'Enter' && doSearch()} placeholder="Canal / proveedor" className="bg-dark-700 border border-dark-600 rounded-lg pl-8 pr-3 py-1.5 text-sm w-48 focus:outline-none focus:border-primary-500" /> +
+ + setFilterDateFrom(e.target.value)} className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none" /> + → + setFilterDateTo(e.target.value)} className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none" /> + +
+
+ + {/* Events table */} +
+
+ {total.toLocaleString()} eventos + {totalPages > 1 && ( +
+ + {page} / {totalPages} + +
+ )} +
+
+ + + + + + + + + + + + + + {loading && } + {!loading && events.length === 0 && ( + + )} + {events.map(ev => ( + + + + + + + + + + ))} + +
FechaTipoCanalProveedorDominioDetalleIntento
Cargando...
No hay eventos registrados — los fallos aparecerán aquí automáticamente
{fmtDate(ev.created_at)} + {ev.event_type === 'caida' ? ( + + Caída + + ) : ev.event_type === 'recuperado' ? ( + + Recuperado + + ) : ( + {ev.event_type} + )} + + {ev.channel_name || `#${ev.channel_id}` || '—'} + {ev.provider_name || '—'}{ev.domain || '—'} + {ev.error_message || '—'} + + {ev.attempt_number ?? '—'} +
+
+
+
+ ) +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +type Tab = 'connections' | 'events' + +export default function Logs() { + const [activeTab, setActiveTab] = useState('connections') + + return ( +
+
+

Registro

+
+ + {/* Tab bar */} +
+ + +
+ + {activeTab === 'connections' && } + {activeTab === 'events' && } +
+ ) +} diff --git a/frontend/src/pages/Providers.tsx b/frontend/src/pages/Providers.tsx new file mode 100644 index 0000000..0c8dded --- /dev/null +++ b/frontend/src/pages/Providers.tsx @@ -0,0 +1,896 @@ +import { useEffect, useRef, useState } from 'react' +import { Plus, Trash2, RefreshCw, Wifi, Search, ChevronDown, ChevronRight, Bell, Edit2 } from 'lucide-react' +import api from '../lib/api' + +interface ProviderUrl { + id: number + url: string + priority: number + is_active: boolean + status: 'unknown' | 'ok' | 'error' | 'timeout' + response_ms: number | null + last_checked_at: string | null +} + +interface Provider { + id: number + name: string + base_url: string + username: string + max_connections: number + is_active: boolean + last_sync_at: string | null + expiry_date: string | null + auto_sync_hours: number + last_sync_categories: string | null // JSON + urls: ProviderUrl[] +} + +function expiryBadge(dateStr: string | null): { label: string; cls: string } | null { + if (!dateStr) return null + const exp = new Date(dateStr) + const daysLeft = Math.ceil((exp.getTime() - Date.now()) / 86400000) + if (daysLeft < 0) return { label: 'Expirado', cls: 'text-red-400 bg-red-900/20 border border-red-800' } + if (daysLeft <= 7) return { label: `Caduca en ${daysLeft}d`, cls: 'text-red-400 bg-red-900/20 border border-red-800' } + if (daysLeft <= 30) return { label: `Caduca en ${daysLeft}d`, cls: 'text-yellow-400 bg-yellow-900/20 border border-yellow-800' } + return { label: `Caduca: ${exp.toLocaleDateString('es-ES')}`, cls: 'text-green-400 bg-green-900/20 border border-green-800' } +} + +interface CatOption { id: string; name: string } +interface AvailableCategories { + live: CatOption[] + movie: CatOption[] + series: CatOption[] +} + +interface UpdateCategory { id: string; name: string; type: string } + +interface FormData { + name: string + base_url: string + username: string + password: string + max_connections: number + auto_sync_hours: number +} + +const TYPES: Array<{ key: keyof AvailableCategories; label: string }> = [ + { key: 'live', label: 'Canales en directo' }, + { key: 'movie', label: 'Películas (VOD)' }, + { key: 'series', label: 'Series' }, +] + +const AUTO_SYNC_OPTIONS = [ + { value: 0, label: 'Desactivado' }, + { value: 6, label: 'Cada 6 horas' }, + { value: 12, label: 'Cada 12 horas' }, + { value: 24, label: 'Cada 24 horas' }, +] + +const empty: FormData = { name: '', base_url: '', username: '', password: '', max_connections: 1, auto_sync_hours: 0 } + +export default function Providers() { + const [providers, setProviders] = useState([]) + const [form, setForm] = useState(empty) + const [showForm, setShowForm] = useState(false) + const [syncing, setSyncing] = useState>(new Set()) + const [syncProgress, setSyncProgress] = useState>({}) + const [syncStats, setSyncStats] = useState>>({}) + const [testing, setTesting] = useState(null) + const [testResult, setTestResult] = useState>({}) + const pollRefs = useRef>({}) + + // Category picker state + const [pickerProvider, setPickerProvider] = useState(null) + const [pickerLoading, setPickerLoading] = useState(false) + const [availableCats, setAvailableCats] = useState({ live: [], movie: [], series: [] }) + const [selectedCats, setSelectedCats] = useState>>({ live: new Set(), movie: new Set(), series: new Set() }) + const [catSearch, setCatSearch] = useState('') + const [collapsed, setCollapsed] = useState>({}) + + // Edit provider modal state + const [editProvider, setEditProvider] = useState(null) + const [editForm, setEditForm] = useState({ name: '', base_url: '', username: '', password: '', max_connections: 1, auto_sync_hours: 0 }) + + // Updates modal state + const [updatesProvider, setUpdatesProvider] = useState(null) + const [updatesLoading, setUpdatesLoading] = useState(false) + const [updatesData, setUpdatesData] = useState([]) + const [selectedUpdates, setSelectedUpdates] = useState>(new Set()) + const [updatesSearch, setUpdatesSearch] = useState('') + const [updatesCount, setUpdatesCount] = useState>({}) + + // URL management state + const [expandedUrls, setExpandedUrls] = useState>(new Set()) + const [addingUrl, setAddingUrl] = useState(null) + const [newUrlForm, setNewUrlForm] = useState({ url: '', priority: 10, is_active: true }) + const [checkingUrls, setCheckingUrls] = useState>(new Set()) + + async function load() { + const { data } = await api.get('/providers/') + setProviders(data) + return data as Provider[] + } + + useEffect(() => { + load().then(async (provs: Provider[]) => { + for (const p of provs) { + try { + const { data } = await api.get(`/providers/${p.id}/sync/status`) + if (data.status === 'running') { + setSyncing(s => new Set(s).add(p.id)) + setSyncProgress(prev => ({ ...prev, [p.id]: data.progress || 'Sincronizando...' })) + startPolling(p.id) + } + } catch { /* ignore */ } + } + }) + return () => { Object.keys(pollRefs.current).forEach(id => { pollRefs.current[+id] = false }) } + }, []) + + function startPolling(id: number) { + pollRefs.current[id] = true + const poll = async () => { + if (!pollRefs.current[id]) return + try { + const { data } = await api.get(`/providers/${id}/sync/status`) + setSyncProgress(p => ({ ...p, [id]: data.progress || '' })) + setSyncStats(s => ({ ...s, [id]: data.stats || {} })) + if (data.status === 'running') { + setTimeout(poll, 1500) + } else { + pollRefs.current[id] = false + setSyncing(s => { const n = new Set(s); n.delete(id); return n }) + setSyncProgress(p => ({ ...p, [id]: data.status === 'done' ? '✓ Sync completado' : `Error: ${data.progress}` })) + load() + } + } catch { + pollRefs.current[id] = false + setSyncing(s => { const n = new Set(s); n.delete(id); return n }) + } + } + poll() + } + + async function submit(e: React.FormEvent) { + e.preventDefault() + await api.post('/providers/', form) + setForm(empty) + setShowForm(false) + load() + } + + async function del(id: number) { + if (!confirm('¿Eliminar este proveedor y todos sus canales?')) return + await api.delete(`/providers/${id}`) + load() + } + + async function test(id: number) { + setTesting(id) + try { + const { data } = await api.post(`/providers/${id}/test`) + setTestResult(r => ({ ...r, [id]: data.connected })) + if (data.connected) load() // refresh to show updated expiry_date + } finally { + setTesting(null) + } + } + + function openEdit(provider: Provider) { + setEditProvider(provider) + setEditForm({ + name: provider.name, + base_url: provider.base_url, + username: provider.username, + password: '', + max_connections: provider.max_connections, + auto_sync_hours: provider.auto_sync_hours, + }) + } + + async function saveEdit() { + if (!editProvider) return + try { + await api.put(`/providers/${editProvider.id}`, editForm) + setEditProvider(null) + load() + } catch (e: any) { + alert('Error al guardar: ' + (e.response?.data?.detail || e.message)) + } + } + + async function openCategoryPicker(provider: Provider) { + setPickerProvider(provider) + setPickerLoading(true) + setCatSearch('') + setCollapsed({}) + try { + const [availRes, syncedRes] = await Promise.all([ + api.get(`/providers/${provider.id}/sync/categories`), + api.get(`/providers/${provider.id}/synced-category-ids`), + ]) + setAvailableCats(availRes.data) + + // Primary: last_sync_categories (explicit selection from previous sync — works for + // all providers including those whose channels share another provider's categories) + // Fallback: synced-category-ids (actual DB state for providers that own their categories) + let live: string[] = syncedRes.data.live + let movie: string[] = syncedRes.data.movie + let series: string[] = syncedRes.data.series + + if (provider.last_sync_categories) { + try { + const lsc = JSON.parse(provider.last_sync_categories) + if ((lsc.live?.length ?? 0) + (lsc.movie?.length ?? 0) + (lsc.series?.length ?? 0) > 0) { + live = lsc.live ?? [] + movie = lsc.movie ?? [] + series = lsc.series ?? [] + } + } catch { /* ignore malformed JSON */ } + } + + setSelectedCats({ + live: new Set(live), + movie: new Set(movie), + series: new Set(series), + }) + } catch { + setPickerProvider(null) + } finally { + setPickerLoading(false) + } + } + + async function startSyncFromPicker() { + if (!pickerProvider) return + const id = pickerProvider.id + setPickerProvider(null) + setSyncing(s => new Set(s).add(id)) + setSyncProgress(p => ({ ...p, [id]: 'Iniciando...' })) + try { + await api.post(`/providers/${id}/sync`, { + live_category_ids: Array.from(selectedCats.live), + movie_category_ids: Array.from(selectedCats.movie), + series_category_ids: Array.from(selectedCats.series), + }) + startPolling(id) + } catch { + setSyncing(s => { const n = new Set(s); n.delete(id); return n }) + setSyncProgress(p => ({ ...p, [id]: 'Error al iniciar sync' })) + } + } + + function toggleCat(type: keyof AvailableCategories, id: string) { + setSelectedCats(prev => { + const n = new Set(prev[type]) + n.has(id) ? n.delete(id) : n.add(id) + return { ...prev, [type]: n } + }) + } + + function selectAll(type: keyof AvailableCategories, cats: CatOption[]) { + setSelectedCats(prev => ({ ...prev, [type]: new Set(cats.map(c => c.id)) })) + } + + function deselectAll(type: keyof AvailableCategories) { + setSelectedCats(prev => ({ ...prev, [type]: new Set() })) + } + + async function openUpdates(provider: Provider) { + setUpdatesProvider(provider) + setUpdatesLoading(true) + setUpdatesSearch('') + setSelectedUpdates(new Set()) + try { + const { data } = await api.get(`/providers/${provider.id}/available-updates`) + setUpdatesData(data.new_categories || []) + } catch { + setUpdatesData([]) + } finally { + setUpdatesLoading(false) + } + } + + async function checkUpdatesCount(providerId: number) { + try { + const { data } = await api.get(`/providers/${providerId}/available-updates`) + setUpdatesCount(prev => ({ ...prev, [providerId]: data.total || 0 })) + } catch { /* ignore */ } + } + + useEffect(() => { + // Check update counts for all providers after load + providers.forEach(p => { + if (p.last_sync_at) checkUpdatesCount(p.id) + }) + }, [providers.length]) + + async function syncSelectedUpdates() { + if (!updatesProvider || selectedUpdates.size === 0) return + const id = updatesProvider.id + + // Group selected updates by type + const live: string[] = [] + const movie: string[] = [] + const series: string[] = [] + updatesData.filter(c => selectedUpdates.has(c.id + '_' + c.type)).forEach(c => { + if (c.type === 'live') live.push(c.id) + else if (c.type === 'movie') movie.push(c.id) + else if (c.type === 'series') series.push(c.id) + }) + + setUpdatesProvider(null) + setSyncing(s => new Set(s).add(id)) + setSyncProgress(p => ({ ...p, [id]: 'Iniciando...' })) + try { + await api.post(`/providers/${id}/sync`, { + live_category_ids: live.length > 0 ? live : undefined, + movie_category_ids: movie.length > 0 ? movie : undefined, + series_category_ids: series.length > 0 ? series : undefined, + }) + startPolling(id) + } catch { + setSyncing(s => { const n = new Set(s); n.delete(id); return n }) + setSyncProgress(p => ({ ...p, [id]: 'Error al iniciar sync' })) + } + } + + function toggleUrlSection(id: number) { + setExpandedUrls(prev => { + const n = new Set(prev) + n.has(id) ? n.delete(id) : n.add(id) + return n + }) + } + + async function addUrl(providerId: number) { + await api.post(`/providers/${providerId}/urls`, newUrlForm) + setAddingUrl(null) + setNewUrlForm({ url: '', priority: 10, is_active: true }) + load() + } + + async function deleteUrl(providerId: number, urlId: number) { + if (!confirm('¿Eliminar esta URL de failover?')) return + await api.delete(`/providers/${providerId}/urls/${urlId}`) + load() + } + + async function checkAllUrls(providerId: number) { + setCheckingUrls(c => new Set(c).add(providerId)) + try { + await api.post(`/providers/${providerId}/urls/check-all`) + load() + } finally { + setCheckingUrls(c => { const n = new Set(c); n.delete(providerId); return n }) + } + } + + function urlStatusBadge(url: ProviderUrl) { + if (url.status === 'ok') { + return ( + + ● {url.response_ms != null ? `${url.response_ms}ms` : 'OK'} + + ) + } + if (url.status === 'error') { + return ● Error + } + if (url.status === 'timeout') { + return ● Timeout + } + return ○ Desconocido + } + + const totalSelected = Object.values(selectedCats).reduce((a, s) => a + s.size, 0) + const totalAvailable = Object.values(availableCats).reduce((a, arr) => a + arr.length, 0) + + const TYPE_LABELS: Record = { live: 'Directo', movie: 'Película', series: 'Serie' } + const TYPE_COLORS: Record = { + live: 'bg-blue-900/30 text-blue-300 border-blue-800', + movie: 'bg-purple-900/30 text-purple-300 border-purple-800', + series: 'bg-green-900/30 text-green-300 border-green-800', + } + + const filteredUpdates = updatesData.filter(c => + !updatesSearch || c.name.toLowerCase().includes(updatesSearch.toLowerCase()) + ) + + return ( +
+
+

Proveedores IPTV

+ +
+ + {showForm && ( +
+

Nueva cuenta de proveedor

+
+ setForm(f => ({ ...f, name: v }))} required /> + setForm(f => ({ ...f, base_url: v }))} required /> + setForm(f => ({ ...f, username: v }))} required /> + setForm(f => ({ ...f, password: v }))} required /> +
+ + setForm(f => ({ ...f, max_connections: +e.target.value }))} + className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" + /> +
+
+ + +
+
+
+ + +
+
+ )} + + {/* Category picker modal */} + {pickerProvider && ( +
+
+
+
+

Seleccionar categorías — {pickerProvider.name}

+ {!pickerLoading && ( +

{totalSelected} de {totalAvailable} categorías seleccionadas

+ )} +
+ +
+ + {pickerLoading ? ( +
+
+ +

Obteniendo categorías del proveedor...

+
+
+ ) : ( + <> +
+
+ + setCatSearch(e.target.value)} + placeholder="Buscar categoría..." + className="w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500" + /> +
+
+ +
+ {TYPES.map(({ key, label }) => { + const cats = availableCats[key].filter(c => + !catSearch || c.name.toLowerCase().includes(catSearch.toLowerCase()) + ) + if (!cats.length && !catSearch) return null + const selCount = cats.filter(c => selectedCats[key].has(c.id)).length + const isCollapsed = collapsed[key] + return ( +
+
setCollapsed(c => ({ ...c, [key]: !c[key] }))} + > +
+ {isCollapsed ? : } + {label} + ({selCount}/{cats.length} sel.) +
+
e.stopPropagation()}> + + +
+
+ {!isCollapsed && ( +
+ {cats.length === 0 ? ( +
Sin resultados
+ ) : cats.map(cat => ( + + ))} +
+ )} +
+ ) + })} +
+ +
+ + +
+ + )} +
+
+ )} + + {/* Updates modal */} + {updatesProvider && ( +
+
+
+
+

Novedades — {updatesProvider.name}

+ {!updatesLoading && ( +

+ {updatesData.length === 0 + ? 'No hay categorías nuevas' + : `${updatesData.length} categorías nuevas sin sincronizar`} +

+ )} +
+ +
+ + {updatesLoading ? ( +
+ +
+ ) : updatesData.length === 0 ? ( +
+ Todo está actualizado ✓ +
+ ) : ( + <> +
+
+ + setUpdatesSearch(e.target.value)} + placeholder="Buscar..." + className="w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500" + /> +
+
+ + + {selectedUpdates.size} seleccionadas +
+
+
+ {filteredUpdates.map(cat => { + const key = cat.id + '_' + cat.type + return ( + + ) + })} +
+
+ + +
+ + )} +
+
+ )} + + {/* Edit provider modal */} + {editProvider && ( +
+
+
+

Editar proveedor — {editProvider.name}

+ +
+
+
+ setEditForm(f => ({ ...f, name: v }))} required /> + setEditForm(f => ({ ...f, base_url: v }))} required /> + setEditForm(f => ({ ...f, username: v }))} required /> +
+ + setEditForm(f => ({ ...f, password: e.target.value }))} + placeholder="••••••••" + className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500" + /> +
+
+ + setEditForm(f => ({ ...f, max_connections: +e.target.value }))} + className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" + /> +
+
+ + +
+
+
+
+ + +
+
+
+ )} + +
+ {providers.map(p => ( +
+
+
+
+ {p.name} + {p.auto_sync_hours > 0 && ( + + Auto {p.auto_sync_hours}h + + )} +
+
+ {p.base_url} • {p.username} • {p.max_connections} conex. + {(() => { const b = expiryBadge(p.expiry_date); return b ? {b.label} : null })()} +
+ {p.last_sync_at &&
Último sync: {new Date(p.last_sync_at).toLocaleString('es-ES')}
} + {testResult[p.id] !== undefined && ( +
+ {testResult[p.id] ? '✓ Conexión OK' : '✗ Sin conexión'} +
+ )} + {syncProgress[p.id] && ( +
+ {syncing.has(p.id) && } + {syncProgress[p.id]} + {syncStats[p.id] && syncing.has(p.id) && ( + + ({Object.entries(syncStats[p.id]).filter(([, v]) => v > 0).map(([k, v]) => `${v} ${k}`).join(', ')}) + + )} +
+ )} +
+
+ + {p.last_sync_at && ( + + )} + + + + +
+
+ + {/* URL management section */} + {expandedUrls.has(p.id) && ( +
+
+ Dominios (failover) +
+ + +
+
+ + {(p.urls ?? []).length === 0 && addingUrl !== p.id && ( +
No hay URLs adicionales configuradas
+ )} + + {(p.urls ?? []).map(u => ( +
+
+
+ {u.url} + {u.priority === 0 && primaria} + {!u.is_active && inactiva} +
+
+
+ {urlStatusBadge(u)} +
+ +
+ ))} + + {addingUrl === p.id && ( +
+
+
+ + setNewUrlForm(f => ({ ...f, url: e.target.value }))} + placeholder="http://..." + className="w-full bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-xs focus:outline-none focus:border-primary-500" + /> +
+
+ + setNewUrlForm(f => ({ ...f, priority: +e.target.value }))} + className="w-full bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-xs" + /> +
+
+
+ +
+ + +
+
+
+ )} +
+ )} +
+ ))} + {providers.length === 0 && ( +
+ No hay proveedores. Añade uno para empezar. +
+ )} +
+
+ ) +} + +function Field({ label, value, onChange, required }: { label: string; value: string; onChange: (v: string) => void; required?: boolean }) { + return ( +
+ + onChange(e.target.value)} + required={required} + className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500" + /> +
+ ) +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..b4bc10f --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,153 @@ +import { useEffect, useRef, useState } from 'react' +import api from '../lib/api' +import { Upload, Trash2, ImageIcon, Info } from 'lucide-react' + +export default function Settings() { + const [hasImage, setHasImage] = useState(false) + const [imageUrl, setImageUrl] = useState('') + const [uploading, setUploading] = useState(false) + const [dragging, setDragging] = useState(false) + const fileRef = useRef(null) + + const checkImage = async () => { + try { + const token = localStorage.getItem('token') + const res = await fetch('/api/admin/settings/blocked-image', { + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + setHasImage(true) + // Use the static file URL (no auth needed) for the tag + setImageUrl('/panel/blocked_image.jpg?t=' + Date.now()) + } else { + setHasImage(false) + setImageUrl('') + } + } catch { + setHasImage(false) + } + } + + useEffect(() => { checkImage() }, []) + + const upload = async (file: File) => { + if (!file.type.startsWith('image/')) { + alert('Solo se permiten imágenes (JPEG, PNG, WebP, GIF)') + return + } + setUploading(true) + try { + const form = new FormData() + form.append('file', file) + await api.post('/settings/blocked-image', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + await checkImage() + } catch (e: any) { + alert('Error al subir la imagen: ' + (e.response?.data?.detail || e.message)) + } finally { + setUploading(false) + } + } + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) upload(file) + e.target.value = '' + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setDragging(false) + const file = e.dataTransfer.files[0] + if (file) upload(file) + } + + const deleteImage = async () => { + if (!confirm('¿Eliminar la imagen de bloqueo?')) return + await api.delete('/settings/blocked-image') + setHasImage(false) + setImageUrl('') + } + + return ( +
+

Ajustes

+ + {/* Blocked image section */} +
+
+

+ + Imagen de conexión bloqueada +

+

+ Esta imagen se mostrará como canal de vídeo cuando un usuario intente reproducir + en un segundo dispositivo habiendo alcanzado su límite de conexiones simultáneas. +

+
+ +
+ + + El zapping normal (cambiar de canal en el mismo dispositivo) sigue funcionando + sin interrupciones. La imagen solo aparece cuando se conecta un segundo dispositivo + diferente con la misma cuenta. + +
+ + {/* Upload area */} +
{ e.preventDefault(); setDragging(true) }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + onClick={() => !uploading && fileRef.current?.click()} + className={` + relative border-2 border-dashed rounded-xl cursor-pointer transition-colors + ${dragging ? 'border-primary-400 bg-primary-500/10' : 'border-dark-600 hover:border-dark-500'} + `} + > + + + {hasImage && imageUrl ? ( +
+ Blocked image +
+ Haz clic para cambiar +
+
+ ) : ( +
+ +
+

Arrastra una imagen o haz clic para subir

+

JPEG, PNG, WebP o GIF · máx. 10 MB

+
+
+ )} + + {uploading && ( +
+
Subiendo...
+
+ )} +
+ + {hasImage && ( +
+ +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx new file mode 100644 index 0000000..3a664be --- /dev/null +++ b/frontend/src/pages/Users.tsx @@ -0,0 +1,439 @@ +import { useEffect, useState } from 'react' +import { Plus, Trash2, Edit2, List, Star, Server, Search } from 'lucide-react' +import api from '../lib/api' + +interface User { id: number; username: string; max_connections: number; expiry_date: string | null; is_active: boolean; is_priority: boolean; created_at: string } +interface GroupedCategory { name: string; type: string; category_ids: number[]; provider_count: number } +interface CatalogEntry { id: number; category_id: number; category_name: string; type: string } +interface ProviderPref { id: number; provider_account_id: number; provider_name: string; priority: number } +interface Provider { id: number; name: string } +interface CustomCat { id: number; name: string; type: string } + +const TYPE_LABELS: Record = { live: 'Canales en directo', movie: 'Películas', series: 'Series' } + +export default function Users() { + const [users, setUsers] = useState([]) + const [showForm, setShowForm] = useState(false) + const [editUser, setEditUser] = useState(null) + const [catalogUser, setCatalogUser] = useState(null) + const [providerUser, setProviderUser] = useState(null) + const [allCategories, setAllCategories] = useState([]) + const [selectedCats, setSelectedCats] = useState>(new Set()) + const [allCustomCats, setAllCustomCats] = useState([]) + const [selectedCustomCats, setSelectedCustomCats] = useState>(new Set()) + const [catSearch, setCatSearch] = useState('') + const [allProviders, setAllProviders] = useState([]) + const [userProviderPrefs, setUserProviderPrefs] = useState([]) + + const [form, setForm] = useState({ username: '', password: '', max_connections: 1, expiry_date: '', is_active: true, is_priority: false }) + + async function load() { + const { data } = await api.get('/users/') + setUsers(data) + } + + useEffect(() => { load() }, []) + + async function submit(e: React.FormEvent) { + e.preventDefault() + const payload = { ...form, expiry_date: form.expiry_date || null } + if (editUser) { + await api.put(`/users/${editUser.id}`, payload) + } else { + await api.post('/users/', payload) + } + resetForm() + load() + } + + function resetForm() { + setForm({ username: '', password: '', max_connections: 1, expiry_date: '', is_active: true, is_priority: false }) + setShowForm(false) + setEditUser(null) + } + + function startEdit(u: User) { + setForm({ username: u.username, password: '', max_connections: u.max_connections, expiry_date: u.expiry_date?.split('T')[0] || '', is_active: u.is_active, is_priority: u.is_priority }) + setEditUser(u) + setShowForm(true) + } + + async function del(id: number) { + if (!confirm('¿Eliminar usuario?')) return + await api.delete(`/users/${id}`) + load() + } + + async function togglePriority(u: User) { + await api.put(`/users/${u.id}`, { is_priority: !u.is_priority }) + load() + } + + async function openCatalog(u: User) { + setCatalogUser(u) + setCatSearch('') + const [grouped, catalog, customCats, userCustomCatalog] = await Promise.all([ + api.get('/catalogs/grouped').then(r => r.data as GroupedCategory[]), + api.get(`/users/${u.id}/catalog`).then(r => r.data as CatalogEntry[]), + api.get('/custom-categories/').then(r => r.data as CustomCat[]), + api.get(`/users/${u.id}/custom-catalog`).then(r => r.data as number[]), + ]) + setAllCategories(grouped) + setSelectedCats(new Set(catalog.map((c: CatalogEntry) => c.category_id))) + setAllCustomCats(customCats) + setSelectedCustomCats(new Set(userCustomCatalog)) + } + + function isGroupSelected(g: GroupedCategory): boolean { + return g.category_ids.some(id => selectedCats.has(id)) + } + + function toggleGroup(g: GroupedCategory, checked: boolean) { + setSelectedCats(prev => { + const n = new Set(prev) + if (checked) { + g.category_ids.forEach(id => n.add(id)) + } else { + g.category_ids.forEach(id => n.delete(id)) + } + return n + }) + } + + function selectAllFiltered(cats: GroupedCategory[]) { + setSelectedCats(prev => { + const n = new Set(prev) + cats.forEach((g: GroupedCategory) => g.category_ids.forEach((id: number) => n.add(id))) + return n + }) + // Also select all filtered custom cats + const filteredCC = allCustomCats.filter(c => !catSearch || c.name.toLowerCase().includes(catSearch.toLowerCase())) + setSelectedCustomCats(prev => { + const n = new Set(prev) + filteredCC.forEach(c => n.add(c.id)) + return n + }) + } + + function selectNoneFiltered(cats: GroupedCategory[]) { + setSelectedCats(prev => { + const n = new Set(prev) + cats.forEach((g: GroupedCategory) => g.category_ids.forEach((id: number) => n.delete(id))) + return n + }) + const filteredCC = allCustomCats.filter(c => !catSearch || c.name.toLowerCase().includes(catSearch.toLowerCase())) + setSelectedCustomCats(prev => { + const n = new Set(prev) + filteredCC.forEach(c => n.delete(c.id)) + return n + }) + } + + async function saveCatalog() { + if (!catalogUser) return + await Promise.all([ + api.post(`/users/${catalogUser.id}/catalog`, Array.from(selectedCats)), + api.post(`/users/${catalogUser.id}/custom-catalog`, Array.from(selectedCustomCats)), + ]) + setCatalogUser(null) + } + + async function openProviderPrefs(u: User) { + setProviderUser(u) + const [providers, prefs] = await Promise.all([ + api.get('/providers/').then(r => r.data as Provider[]), + api.get(`/users/${u.id}/providers`).then(r => r.data as ProviderPref[]), + ]) + setAllProviders(providers) + setUserProviderPrefs(prefs) + } + + async function addProviderPref(providerId: number) { + if (!providerUser) return + await api.post(`/users/${providerUser.id}/providers`, { provider_account_id: providerId, priority: userProviderPrefs.length }) + const { data } = await api.get(`/users/${providerUser.id}/providers`) + setUserProviderPrefs(data) + } + + async function removeProviderPref(providerId: number) { + if (!providerUser) return + await api.delete(`/users/${providerUser.id}/providers/${providerId}`) + const { data } = await api.get(`/users/${providerUser.id}/providers`) + setUserProviderPrefs(data) + } + + const prefProviderIds = new Set(userProviderPrefs.map(p => p.provider_account_id)) + + // Catalog modal filtered + grouped by type + const filteredCats = allCategories.filter(c => + !catSearch || c.name.toLowerCase().includes(catSearch.toLowerCase()) + ) + const filteredCustomCats = allCustomCats.filter(c => + !catSearch || c.name.toLowerCase().includes(catSearch.toLowerCase()) + ) + const selectedGroupCount = allCategories.filter(g => isGroupSelected(g)).length + selectedCustomCats.size + + return ( +
+
+

Usuarios

+ +
+ + {showForm && ( +
+

{editUser ? 'Editar usuario' : 'Nuevo usuario'}

+
+
+ + setForm(f => ({ ...f, username: e.target.value }))} required disabled={!!editUser} + className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" /> +
+
+ + setForm(f => ({ ...f, password: e.target.value }))} required={!editUser} + className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" /> +
+
+ + setForm(f => ({ ...f, max_connections: +e.target.value }))} + className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" /> +
+
+ + setForm(f => ({ ...f, expiry_date: e.target.value }))} + className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" /> +
+
+ + +
+
+
+ + +
+
+ )} + + {/* Catalog assignment modal */} + {catalogUser && ( +
+
+
+
+

Catálogo de {catalogUser.username}

+

{selectedGroupCount} de {allCategories.length + allCustomCats.length} categorías seleccionadas

+
+ +
+ + {/* Search + bulk actions */} +
+
+ + setCatSearch(e.target.value)} + placeholder="Buscar categoría..." + className="w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500" + /> +
+
+ + +
+
+ +
+ {(['live', 'movie', 'series'] as const).map(type => { + const cats = filteredCats.filter(c => c.type === type) + if (!cats.length) return null + return ( +
+

+ {TYPE_LABELS[type]} + {cats.filter(g => isGroupSelected(g)).length}/{cats.length} +

+
+ {cats.map(cat => ( + + ))} +
+
+ ) + })} + {filteredCats.length === 0 && filteredCustomCats.length === 0 && ( +
Sin resultados
+ )} + + {/* Custom categories section */} + {filteredCustomCats.length > 0 && ( +
+

+ + Categorías personalizadas + {filteredCustomCats.filter(c => selectedCustomCats.has(c.id)).length}/{filteredCustomCats.length} +

+
+ {filteredCustomCats.map(cat => ( + + ))} +
+
+ )} +
+ +
+ + +
+
+
+ )} + + {/* Provider preferences modal */} + {providerUser && ( +
+
+
+

Proveedores preferidos — {providerUser.username}

+ +
+
+

Los proveedores marcados se intentan primero al asignar un slot. Si ninguno está disponible, se usa cualquiera con slot libre.

+ {allProviders.map(p => { + const isPref = prefProviderIds.has(p.id) + return ( +
+ {p.name} + +
+ ) + })} + {allProviders.length === 0 && ( +
No hay proveedores configurados
+ )} +
+
+ +
+
+
+ )} + +
+ + + + + + + + + + + + {users.map(u => ( + + + + + + + + ))} + {users.length === 0 && ( + + )} + +
UsuarioConexionesExpiraciónEstadoAcciones
+
+ {u.is_priority && } + {u.username} +
+
{u.max_connections}{u.expiry_date ? new Date(u.expiry_date).toLocaleDateString('es-ES') : '—'} + + {u.is_active ? 'Activo' : 'Inactivo'} + + +
+ + + + + +
+
No hay usuarios
+
+
+ ) +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..d317206 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,24 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: { + colors: { + primary: { + 50: '#f0f4ff', + 500: '#3b5bdb', + 600: '#3451c7', + 700: '#2c44a8', + 900: '#1a2a6b', + }, + dark: { + 900: '#0f172a', + 800: '#1e293b', + 700: '#334155', + 600: '#475569', + }, + }, + }, + }, + plugins: [], +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..4c77361 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..aaf6498 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + base: '/panel/', + server: { + proxy: { + '/api': 'http://localhost:8000', + }, + }, + build: { + outDir: '../backend/static', + emptyOutDir: true, + }, +}) diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..9e764f0 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# KiraStream installation/startup script for PCT 117 (kiraIPTV) +set -e + +APP_DIR=/opt/kirastream +VENV="$APP_DIR/venv" +PYTHON="$VENV/bin/python" +UV="$VENV/bin/uvicorn" + +echo "=== KiraStream startup ===" + +# Fix locale for Redis +export LANG=C +export LC_ALL=C + +# --- PostgreSQL --- +if ! pg_ctlcluster 17 main status &>/dev/null; then + echo "Starting PostgreSQL..." + pg_ctlcluster 17 main start +fi + +# Ensure DB exists +su - postgres -s /bin/bash -c "createdb -O kirastream kirastream 2>/dev/null || true" + +# --- Redis --- +if ! redis-cli ping &>/dev/null; then + echo "Starting Redis..." + LANG=C LC_ALL=C redis-server \ + --port 6379 \ + --bind 127.0.0.1 \ + --daemonize yes \ + --logfile /var/log/redis/kirastream-redis.log \ + --dir /var/lib/redis/ + sleep 1 +fi + +# --- Backend --- +echo "Starting KiraStream backend..." +cd "$APP_DIR" +exec "$UV" backend.main:app \ + --host 0.0.0.0 \ + --port 8000 \ + --workers 4 \ + --loop uvloop \ + --log-level info \ + --access-log