From f5d6745ed08d52eb36456fa0bd07ec5520b04067 Mon Sep 17 00:00:00 2001 From: joaquin Date: Sun, 17 May 2026 23:22:05 +0200 Subject: [PATCH] Initial commit: KiraTV IPTV proxy server Full-stack IPTV management system with Xtream Codes API compatibility, multi-account provider fan-out, Jellyfin local library integration, real-time dashboard, EPG support, and A/V-sync-safe stream recovery. --- .env.example | 24 + .gitignore | 56 ++ README.md | 44 ++ backend/__init__.py | 0 backend/api/__init__.py | 0 backend/api/admin/__init__.py | 0 backend/api/admin/catalogs.py | 90 +++ backend/api/admin/custom_categories.py | 186 +++++ backend/api/admin/epg_admin.py | 60 ++ backend/api/admin/jellyfin_admin.py | 406 +++++++++++ backend/api/admin/logs.py | 130 ++++ backend/api/admin/monitoring.py | 132 ++++ backend/api/admin/providers.py | 746 +++++++++++++++++++ backend/api/admin/settings.py | 38 + backend/api/admin/users.py | 267 +++++++ backend/api/auth.py | 47 ++ backend/api/xtream.py | 928 ++++++++++++++++++++++++ backend/config.py | 34 + backend/core/__init__.py | 0 backend/core/catalog.py | 78 ++ backend/core/epg_manager.py | 98 +++ backend/core/jellyfin_client.py | 120 +++ backend/core/pool.py | 276 +++++++ backend/core/probe.py | 112 +++ backend/core/restream.py | 350 +++++++++ backend/core/vod_tracker.py | 164 +++++ backend/core/xtream_client.py | 87 +++ backend/database.py | 31 + backend/main.py | 140 ++++ backend/models/__init__.py | 15 + backend/models/channel.py | 61 ++ backend/models/custom_category.py | 29 + backend/models/epg.py | 13 + backend/models/jellyfin.py | 47 ++ backend/models/log.py | 19 + backend/models/notification.py | 16 + backend/models/provider.py | 24 + backend/models/user.py | 66 ++ frontend/index.html | 13 + frontend/package.json | 30 + frontend/postcss.config.js | 6 + frontend/src/App.tsx | 41 ++ frontend/src/components/Layout.tsx | 238 ++++++ frontend/src/index.css | 12 + frontend/src/lib/api.ts | 30 + frontend/src/main.tsx | 10 + frontend/src/pages/Catalogs.tsx | 87 +++ frontend/src/pages/CustomCategories.tsx | 337 +++++++++ frontend/src/pages/Dashboard.tsx | 360 +++++++++ frontend/src/pages/EPG.tsx | 118 +++ frontend/src/pages/Jellyfin.tsx | 480 ++++++++++++ frontend/src/pages/Login.tsx | 74 ++ frontend/src/pages/Logs.tsx | 288 ++++++++ frontend/src/pages/Providers.tsx | 726 ++++++++++++++++++ frontend/src/pages/Settings.tsx | 153 ++++ frontend/src/pages/Users.tsx | 439 +++++++++++ frontend/tailwind.config.js | 24 + frontend/tsconfig.json | 17 + frontend/vite.config.ts | 16 + kirastream.service | 25 + nginx/kirastream.conf | 63 ++ scripts/install.sh | 46 ++ 62 files changed, 8567 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/__init__.py create mode 100644 backend/api/__init__.py create mode 100644 backend/api/admin/__init__.py create mode 100644 backend/api/admin/catalogs.py create mode 100644 backend/api/admin/custom_categories.py create mode 100644 backend/api/admin/epg_admin.py create mode 100644 backend/api/admin/jellyfin_admin.py create mode 100644 backend/api/admin/logs.py create mode 100644 backend/api/admin/monitoring.py create mode 100644 backend/api/admin/providers.py create mode 100644 backend/api/admin/settings.py create mode 100644 backend/api/admin/users.py create mode 100644 backend/api/auth.py create mode 100644 backend/api/xtream.py create mode 100644 backend/config.py create mode 100644 backend/core/__init__.py create mode 100644 backend/core/catalog.py create mode 100644 backend/core/epg_manager.py create mode 100644 backend/core/jellyfin_client.py create mode 100644 backend/core/pool.py create mode 100644 backend/core/probe.py create mode 100644 backend/core/restream.py create mode 100644 backend/core/vod_tracker.py create mode 100644 backend/core/xtream_client.py create mode 100644 backend/database.py create mode 100644 backend/main.py create mode 100644 backend/models/__init__.py create mode 100644 backend/models/channel.py create mode 100644 backend/models/custom_category.py create mode 100644 backend/models/epg.py create mode 100644 backend/models/jellyfin.py create mode 100644 backend/models/log.py create mode 100644 backend/models/notification.py create mode 100644 backend/models/provider.py create mode 100644 backend/models/user.py create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Catalogs.tsx create mode 100644 frontend/src/pages/CustomCategories.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/EPG.tsx create mode 100644 frontend/src/pages/Jellyfin.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Logs.tsx create mode 100644 frontend/src/pages/Providers.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/Users.tsx create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 kirastream.service create mode 100644 nginx/kirastream.conf create mode 100644 scripts/install.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8e56a05 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Copy this file to /opt/kirastream/.env and fill in your values + +DATABASE_URL=postgresql+asyncpg://kirastream:CHANGE_ME@localhost/kirastream +REDIS_URL=redis://localhost:6379/0 + +# JWT — generate with: openssl rand -hex 32 +SECRET_KEY=CHANGE_ME + +# Admin panel credentials +ADMIN_USERNAME=admin +ADMIN_PASSWORD=CHANGE_ME + +# Stream settings (optional, defaults shown) +# STREAM_CHUNK_SIZE=65536 +# STREAM_QUEUE_MAXSIZE=100 +# STREAM_CONNECT_TIMEOUT=10 +# STREAM_READ_TIMEOUT=30 + +# EPG / M3U cache (optional) +# EPG_REFRESH_HOURS=12 +# M3U_CACHE_MINUTES=30 + +# Logging +# LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..663b210 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +*.egg +.eggs/ +*.so +.venv/ +venv/ +env/ +ENV/ +pip-wheel-metadata/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +htmlcov/ +.coverage +coverage.xml +*.cover + +# Alembic +backend/migrations/versions/ + +# Environment / secrets +.env +.env.* +!.env.example +*.env + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +# Logs +logs/ +*.log + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Backups generated during dev +*.bak +*.orig diff --git a/README.md b/README.md new file mode 100644 index 0000000..11ce890 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# KiraTV + +Sistema de proxy y gestión IPTV con soporte multiusuario y fan-out de streams. + +## Stack + +- **Backend**: Python 3.13 + FastAPI + asyncio +- **Base de datos**: PostgreSQL 17 + SQLAlchemy 2 + Alembic +- **Cache / estado**: Redis 8 +- **Frontend**: React 18 + Vite + TailwindCSS +- **Proxy inverso**: Nginx + +## Características + +- Compatible con API Xtream Codes (TiviMate, Smarters, IPTV Pro…) +- Fan-out de streams: N usuarios ven el mismo canal → 1 solo slot del proveedor +- Soporte multi-cuenta del mismo proveedor con balanceo de slots +- Catálogos personalizados por usuario (live, VOD, series) +- Integración Jellyfin: biblioteca local como categorías KiraTV +- EPG XMLTV con merge por tvg-id +- Dashboard en tiempo real (WebSocket): qué usuario ve qué canal y qué slot usa +- Reconexión automática con detección de stall y A/V sync garantizado + +## Instalación + +```bash +cp .env.example /opt/kirastream/.env +# Editar .env con tus valores reales + +cd /opt/kirastream/frontend +npm install && npm run build + +cd /opt/kirastream +python3 -m venv venv +venv/bin/pip install -r backend/requirements.txt +venv/bin/python -m alembic upgrade head +``` + +## Configuración + +Ver `.env.example` para todas las variables disponibles. + +La configuración de Nginx está en `nginx/kirastream.conf`. +El servicio systemd está en `kirastream.service`. 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..684fa9f --- /dev/null +++ b/backend/api/admin/logs.py @@ -0,0 +1,130 @@ +"""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 ..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() + + +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..e8b788f --- /dev/null +++ b/backend/api/admin/providers.py @@ -0,0 +1,746 @@ +"""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 ...database import get_db, AsyncSessionLocal +from ...models.provider import ProviderAccount +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 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 + + 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) + .where(ProviderAccount.id.notin_(_jellyfin_provider_subq())) + .order_by(ProviderAccount.id) + ) + return result.scalars().all() + + +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} + + +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..22f2c81 --- /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 = 100 + 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..d2de673 --- /dev/null +++ b/backend/core/epg_manager.py @@ -0,0 +1,98 @@ +"""EPG manager: fetch XMLTV sources, merge by tvg-id, serve filtered by user catalog.""" +import asyncio +import gzip +import logging +import time +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=asyncio.get_event_loop().time()) # 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/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..625d2aa --- /dev/null +++ b/backend/core/pool.py @@ -0,0 +1,276 @@ +"""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), "" + ) + group = BroadcastGroup( + channel_id=channel_id, + stream_url=stream_url, + 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 _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..8922881 --- /dev/null +++ b/backend/core/restream.py @@ -0,0 +1,350 @@ +"""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 +opens a fresh upstream connection. If the connection fails or stalls the loop +sends SENTINEL to every client (forcing the player to reconnect from scratch), +sleeps briefly, then retries the upstream connection. + +Sending SENTINEL on reconnect is intentional: keeping the TCP connection alive +while splicing in chunks from a new provider connection causes a PTS/DTS jump +that most players cannot reconcile silently — the result is permanent audio +desync. A clean player reconnect gives the player a fresh timestamp timeline +and guarantees A/V sync from the first frame of the new connection. + +Stall detection +--------------- +Each chunk read is wrapped in asyncio.wait_for(STALL_TIMEOUT). If the +upstream stops sending bytes for STALL_TIMEOUT seconds we treat it as a dead +stream and reconnect immediately rather than waiting for a TCP timeout that +could take minutes. +""" +import asyncio +import logging +import time +import uuid +from dataclasses import dataclass, field +from typing import AsyncIterator + +import aiohttp + +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 of silence → reconnect (fast stall detection) +MAX_RETRIES = 999 # effectively infinite — pool stops the group when 0 clients remain +# Back-off: 0.5 s, 1 s, 2 s, 4 s … capped at 5 s +_BACKOFF_CAP = 5.0 + +_SENTINEL = object() # signals end-of-stream to client queues + + +@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_url: str, + provider_account_id: int, + channel_name: str = "", + provider_name: str = "", + ): + self.channel_id = channel_id + self.channel_name = channel_name + self.stream_url = 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 = {} # populated by background ffprobe + self._reconnect_count: int = 0 + self._status: str = "connecting" # connecting | ok | reconnecting | stopped + self._last_data_at: float = time.time() + + # ------------------------------------------------------------------ + # 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}" + ) + # Probe stream info in background — does NOT delay the first chunk + 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" + 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, + # Health + "status": self._status, + "reconnect_count": self._reconnect_count, + "last_data_ago": round(time.time() - self._last_data_at, 1), + # Stream metadata from ffprobe + "stream_info": self.stream_info, + } + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + 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: + """ + Outer retry loop. Each iteration calls _pump_once() which runs until + the upstream connection dies or stalls. On failure we back off and + retry without touching client connections. + """ + consecutive = 0 + base_wait = 1.0 + + try: + while self._running: + try: + await self._pump_once() + # _pump_once returned cleanly (self._running went False) + break + except asyncio.CancelledError: + raise + except Exception as e: + if not self._running: + break + consecutive += 1 + self._reconnect_count += 1 + wait = min(base_wait * (2 ** (consecutive - 1)), _BACKOFF_CAP) + self._status = "reconnecting" + logger.warning( + f"[ch{self.channel_id}] Upstream error (attempt {consecutive}), " + f"reconnecting in {wait:.1f}s: {e}" + ) + # Send SENTINEL to every client so the player drops its + # own buffers and reconnects from scratch. This is the + # only reliable way to prevent A/V desync: if we keep the + # TCP connection alive but splice in chunks from a new + # provider connection, the player sees a PTS/DTS jump it + # cannot reconcile silently. A clean player reconnect + # gives it a fresh timeline and guarantees sync. + async with self._lock: + for handle in self._clients.values(): + _drain_queue(handle.queue) + try: + handle.queue.put_nowait(_SENTINEL) + except asyncio.QueueFull: + pass + await asyncio.sleep(wait) + finally: + self._status = "stopped" + self._running = False + # Signal all waiting clients that the stream is over + 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) -> None: + """ + Open one upstream connection and pump until EOF, stall, or error. + Resets consecutive-failure counter on first successful chunk. + """ + timeout = aiohttp.ClientTimeout( + connect=settings.stream_connect_timeout, + sock_read=None, # we control read timeouts via wait_for ourselves + ) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(self.stream_url, ssl=False) as resp: + if resp.status not in (200, 206): + raise RuntimeError(f"Provider returned HTTP {resp.status}") + + self._status = "ok" + self._last_data_at = time.time() + logger.info( + f"[ch{self.channel_id}] Stream connected " + f"(reconnects so far: {self._reconnect_count})" + ) + + while self._running: + # --- read one chunk with stall timeout --- + try: + chunk = await asyncio.wait_for( + resp.content.readany(), timeout=STALL_TIMEOUT + ) + except asyncio.TimeoutError: + raise RuntimeError( + f"Stream stalled: no data for {STALL_TIMEOUT:.0f}s" + ) + + if not chunk: + raise RuntimeError("Provider closed the stream (EOF)") + + # --- 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 + + # --- distribute to clients --- + await self._dispatch(chunk, now) + + 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: + # Drop-newest: discard this chunk for the slow client. + # Preserves the already-buffered sequence → no A/V desync from gaps. + 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..c7b2df1 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,31 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +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 # noqa: ensure all models imported + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..f3767f5 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,140 @@ +"""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 .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()) + logger.info("KiraStream ready") + yield + logger.info("KiraStream shutting down...") + await pool.close() + epg_task.cancel() + auto_sync_task.cancel() + for t in (epg_task, auto_sync_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..dc5b1dc --- /dev/null +++ b/backend/models/provider.py @@ -0,0 +1,24 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, 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") 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/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.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..a0f2f43 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,360 @@ +import { useEffect, useRef, useState } from 'react' +import { Activity, Server, Users, Wifi, Clock, X, Film, Tv, Clapperboard, RefreshCw, AlertTriangle } 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 + // health (live streams only) + status?: 'connecting' | 'ok' | 'reconnecting' | 'stopped' + reconnect_count?: number + last_data_ago?: number + stream_info?: StreamInfo +} + +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 res = info.height >= 1060 ? '4K' : info.height >= 1060 ? '4K' + : info.height >= 2000 ? '4K' + : info.height >= 1060 ? 'FHD' + : info.height >= 1040 ? 'FHD' + : info.height >= 700 ? 'HD' + : info.height >= 460 ? 'SD' + : 'LD' + 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, lastDataAgo }: { + status?: string + reconnectCount?: number + lastDataAgo?: 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} +} + +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 anyReconnecting = streams.some(s => s.status === 'reconnecting') + + return ( +
+
+

Dashboard

+
+ {anyReconnecting && ( + + + Reconectando stream(s)... + + )} +
+ + {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)} /> +
+ + {/* 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) => ( + + + + + + + + + + + + ))} + +
TipoCanalProveedorStreamEstadoUsuarios↓ / ↑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.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 }: { icon: React.ReactNode; label: string; value: string | number }) { + 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..7b9d0ff --- /dev/null +++ b/frontend/src/pages/Logs.tsx @@ -0,0 +1,288 @@ +import { useEffect, useState, useCallback } from 'react' +import api from '../lib/api' +import { Search, Trash2, RefreshCw, Users, Tv, Database, Clock } from 'lucide-react' +import clsx from 'clsx' + +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 }[] +} + +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' } + +export default function Logs() { + 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?')) return + await api.delete('/logs') + setLogs([]) + setTotal(0) + loadStats() + } + + const totalPages = Math.ceil(total / limit) + + return ( +
+
+

Registros de conexión

+
+ + +
+
+ + {/* Stats cards */} + {stats && ( +
+ + + + +
+ )} + + {/* Top lists */} + {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

} +
+
+
+ )} + + {/* Filters */} +
+
+
+ + 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" + /> + +
+
+ + {/* Table */} +
+
+ {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)}
+
+
+
+ ) +} + +function StatCard({ icon: Icon, label, value, color }: { icon: typeof Clock; label: string; value: string; color: string }) { + return ( +
+ +
+

{label}

+

{value}

+
+
+ ) +} diff --git a/frontend/src/pages/Providers.tsx b/frontend/src/pages/Providers.tsx new file mode 100644 index 0000000..68fef38 --- /dev/null +++ b/frontend/src/pages/Providers.tsx @@ -0,0 +1,726 @@ +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 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 +} + +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>({}) + + 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' })) + } + } + + 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 && ( + + )} + + + +
+
+ ))} + {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/kirastream.service b/kirastream.service new file mode 100644 index 0000000..5edc271 --- /dev/null +++ b/kirastream.service @@ -0,0 +1,25 @@ +[Unit] +Description=KiraStream IPTV Server +After=network.target postgresql.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/kirastream + +ExecStartPre=/bin/bash -c 'pg_ctlcluster 17 main start 2>/dev/null; true' +ExecStartPre=/bin/bash -c 'redis-cli ping 2>/dev/null || (LANG=C LC_ALL=C redis-server --port 6379 --bind 127.0.0.1 --daemonize yes --dir /var/lib/redis/ && sleep 1); true' +ExecStartPre=/bin/bash -c 'pkill -9 uvicorn 2>/dev/null; sleep 1; true' + +ExecStart=/opt/kirastream/venv/bin/uvicorn backend.main:app \ + --host 0.0.0.0 --port 8000 --workers 1 --log-level info + +StandardOutput=append:/opt/kirastream/logs/backend.log +StandardError=append:/opt/kirastream/logs/backend.log + +Restart=always +RestartSec=5 +KillMode=control-group + +[Install] +WantedBy=multi-user.target diff --git a/nginx/kirastream.conf b/nginx/kirastream.conf new file mode 100644 index 0000000..b7b4e47 --- /dev/null +++ b/nginx/kirastream.conf @@ -0,0 +1,63 @@ +upstream kirastream_backend { + server 127.0.0.1:8000; + keepalive 32; +} + +server { + listen 80 default_server; + server_name _; + + # Streaming: long timeouts, no buffering + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 10s; + + # Frontend static build + location /admin { + alias /opt/kirastream/backend/static; + try_files $uri $uri/ /admin/index.html; + } + + # API and Xtream Codes endpoints — proxy to FastAPI + location / { + proxy_pass http://kirastream_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # No buffering for streams + proxy_buffering off; + proxy_cache off; + add_header X-Accel-Buffering no; + } + + # WebSocket monitoring + location /api/admin/monitoring/ws { + proxy_pass http://kirastream_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 86400s; + } + + # Static files for frontend (direct serve) + location /assets/ { + alias /opt/kirastream/backend/static/assets/; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # Gzip + gzip on; + gzip_types application/json application/javascript text/css text/plain application/x-mpegurl; + gzip_min_length 1024; + + # Logging + access_log /var/log/nginx/kirastream_access.log; + error_log /var/log/nginx/kirastream_error.log; +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 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