"""Admin API: provider account management + sync.""" import asyncio import json import logging from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select, delete from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel from sqlalchemy.orm import selectinload from ...database import get_db, AsyncSessionLocal from ...models.provider import ProviderAccount, ProviderUrl from ...models.channel import Category, Channel, ChannelProviderMap, ContentType from ...models.notification import NotificationDismissal from ...models.epg import EpgSource from ...core.xtream_client import XtreamClient from ...core.epg_manager import refresh_epg from ..auth import get_current_admin logger = logging.getLogger(__name__) router = APIRouter(prefix="/providers", tags=["providers"]) # Track ongoing sync tasks: provider_id -> status dict _sync_status: dict[int, dict] = {} class ProviderCreate(BaseModel): name: str base_url: str username: str password: str max_connections: int = 1 auto_sync_hours: int = 0 # 0=disabled, 6/12/24=interval in hours class ProviderUpdate(BaseModel): name: str base_url: str username: str password: str | None = None # None = keep existing max_connections: int = 1 auto_sync_hours: int = 0 class ProviderUrlOut(BaseModel): id: int url: str priority: int is_active: bool status: str response_ms: int | None = None last_checked_at: datetime | None = None class Config: from_attributes = True class ProviderOut(BaseModel): id: int name: str base_url: str username: str max_connections: int is_active: bool last_sync_at: datetime | None expiry_date: datetime | None = None auto_sync_hours: int last_sync_categories: str | None # raw JSON, parsed on frontend urls: list[ProviderUrlOut] = [] class Config: from_attributes = True class SyncRequest(BaseModel): live_category_ids: list[str] | None = None # None = import all movie_category_ids: list[str] | None = None series_category_ids: list[str] | None = None def _jellyfin_provider_subq(): """Subquery returning provider_account_ids used by Jellyfin configs.""" from ...models.jellyfin import JellyfinConfig return ( select(JellyfinConfig.provider_account_id) .where(JellyfinConfig.provider_account_id.isnot(None)) .scalar_subquery() ) @router.get("/", response_model=list[ProviderOut]) async def list_providers(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): result = await db.execute( select(ProviderAccount) .options(selectinload(ProviderAccount.extra_urls)) .where(ProviderAccount.id.notin_(_jellyfin_provider_subq())) .order_by(ProviderAccount.id) ) providers = result.scalars().all() # Map extra_urls to the urls field expected by ProviderOut for p in providers: p.urls = p.extra_urls return providers async def _sync_epg_source(db: AsyncSession, provider: ProviderAccount) -> None: """Create or update the auto-managed EPG source for a provider.""" url = XtreamClient(provider.base_url, provider.username, provider.password).get_xmltv_url() name = f"EPG - {provider.name}" result = await db.execute(select(EpgSource).where(EpgSource.name == name)) source = result.scalar_one_or_none() if source: source.url = url else: db.add(EpgSource(name=name, url=url, is_active=True)) await db.commit() async def _delete_epg_source(db: AsyncSession, provider: ProviderAccount) -> None: name = f"EPG - {provider.name}" result = await db.execute(select(EpgSource).where(EpgSource.name == name)) source = result.scalar_one_or_none() if source: await db.delete(source) await db.commit() @router.post("/", response_model=ProviderOut) async def create_provider(data: ProviderCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): provider = ProviderAccount(**data.model_dump()) db.add(provider) await db.commit() await db.refresh(provider) await _sync_epg_source(db, provider) return provider @router.put("/{provider_id}", response_model=ProviderOut) async def update_provider(provider_id: int, data: ProviderUpdate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) provider = result.scalar_one_or_none() if not provider: raise HTTPException(404, "Provider not found") provider.name = data.name provider.base_url = data.base_url provider.username = data.username provider.max_connections = data.max_connections provider.auto_sync_hours = data.auto_sync_hours if data.password: provider.password = data.password await db.commit() await db.refresh(provider) await _sync_epg_source(db, provider) return provider @router.get("/{provider_id}/synced-category-ids") async def get_synced_category_ids(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): """Returns the category IDs currently synced in DB for this provider (by content type).""" result = await db.execute( select(Category.provider_category_id, Category.type) .where(Category.provider_account_id == provider_id) ) rows = result.all() return { "live": [r.provider_category_id for r in rows if r.type == ContentType.live], "movie": [r.provider_category_id for r in rows if r.type == ContentType.movie], "series": [r.provider_category_id for r in rows if r.type == ContentType.series], } @router.get("/{provider_id}/available-updates") async def get_available_updates(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): """Return categories from the provider that are NOT yet synced into the DB.""" result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) provider = result.scalar_one_or_none() if not provider: raise HTTPException(404, "Provider not found") client = XtreamClient(provider.base_url, provider.username, provider.password) live_task = asyncio.create_task(client.get_live_categories()) vod_task = asyncio.create_task(client.get_vod_categories()) series_task = asyncio.create_task(client.get_series_categories()) live_raw, vod_raw, series_raw = await asyncio.gather(live_task, vod_task, series_task, return_exceptions=True) def to_list(raw): if isinstance(raw, Exception): return [] return raw if isinstance(raw, list) else [] # Get all category provider_category_ids already in DB for this provider db_cats = await db.execute( select(Category.provider_category_id, Category.type) .where(Category.provider_account_id == provider_id) ) synced: set[tuple] = {(str(r.provider_category_id), r.type) for r in db_cats} def filter_new(raw, ctype: ContentType): result = [] for c in to_list(raw): cat_id = str(c.get("category_id", "")) if not cat_id: continue if (cat_id, ctype) not in synced: result.append({"id": cat_id, "name": c.get("category_name", ""), "type": ctype.value}) return result new_live = filter_new(live_raw, ContentType.live) new_movie = filter_new(vod_raw, ContentType.movie) new_series = filter_new(series_raw, ContentType.series) return { "provider_id": provider_id, "provider_name": provider.name, "new_categories": new_live + new_movie + new_series, "total": len(new_live) + len(new_movie) + len(new_series), } @router.delete("/{provider_id}") async def delete_provider(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) provider = result.scalar_one_or_none() if not provider: raise HTTPException(404, "Provider not found") await _delete_epg_source(db, provider) await db.delete(provider) await db.commit() return {"ok": True} @router.get("/notifications/all") async def get_all_notifications(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): """Return new (unsynced + undismissed) categories across all providers (Jellyfin excluded).""" providers_result = await db.execute( select(ProviderAccount).where( ProviderAccount.is_active == True, # noqa: E712 ProviderAccount.last_sync_at.isnot(None), ProviderAccount.id.notin_(_jellyfin_provider_subq()), ) ) providers = providers_result.scalars().all() # Load all dismissals dismissals_result = await db.execute(select(NotificationDismissal)) dismissed: set[tuple] = { (str(d.provider_id), d.category_id_str, d.category_type) for d in dismissals_result.scalars().all() } notifications = [] for provider in providers: client = XtreamClient(provider.base_url, provider.username, provider.password) try: live_raw, vod_raw, series_raw = await asyncio.gather( client.get_live_categories(), client.get_vod_categories(), client.get_series_categories(), return_exceptions=True, ) except Exception: continue db_cats = await db.execute( select(Category.provider_category_id, Category.type) .where(Category.provider_account_id == provider.id) ) synced: set[tuple] = {(str(r.provider_category_id), r.type) for r in db_cats} def collect_new(raw, ctype: ContentType): if isinstance(raw, Exception): return [] items = [] for c in (raw if isinstance(raw, list) else []): cat_id = str(c.get("category_id", "")) if not cat_id: continue if (cat_id, ctype) in synced: continue if (str(provider.id), cat_id, ctype.value) in dismissed: continue items.append({"id": cat_id, "name": c.get("category_name", ""), "type": ctype.value}) return items new_cats = ( collect_new(live_raw, ContentType.live) + collect_new(vod_raw, ContentType.movie) + collect_new(series_raw, ContentType.series) ) if new_cats: notifications.append({ "provider_id": provider.id, "provider_name": provider.name, "categories": new_cats, }) total = sum(len(n["categories"]) for n in notifications) return {"notifications": notifications, "total": total} @router.get("/notifications/count") async def get_notifications_count(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): """Fast count of undismissed new categories (checks DB only, no remote API calls).""" providers_result = await db.execute( select(ProviderAccount).where(ProviderAccount.is_active == True, ProviderAccount.last_sync_at.isnot(None)) # noqa: E712 ) providers = providers_result.scalars().all() dismissals_result = await db.execute(select(NotificationDismissal)) dismissed: set[tuple] = { (str(d.provider_id), d.category_id_str, d.category_type) for d in dismissals_result.scalars().all() } # Compare last_sync_categories to what's available total = 0 for provider in providers: if not provider.last_sync_categories: continue try: last = json.loads(provider.last_sync_categories) except Exception: continue # We can't do a fast count without hitting remote — just return 0 here # Real count comes from get_all_notifications return {"total": total} class DismissRequest(BaseModel): provider_id: int category_id: str category_type: str @router.post("/notifications/dismiss") async def dismiss_notification(data: DismissRequest, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): existing = await db.execute( select(NotificationDismissal).where( NotificationDismissal.provider_id == data.provider_id, NotificationDismissal.category_id_str == data.category_id, NotificationDismissal.category_type == data.category_type, ) ) if not existing.scalar_one_or_none(): db.add(NotificationDismissal( provider_id=data.provider_id, category_id_str=data.category_id, category_type=data.category_type, )) await db.commit() return {"ok": True} @router.post("/notifications/dismiss-provider/{provider_id}") async def dismiss_all_for_provider(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): """Dismiss all pending notifications for a specific provider.""" result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) provider = result.scalar_one_or_none() if not provider: raise HTTPException(404, "Provider not found") client = XtreamClient(provider.base_url, provider.username, provider.password) try: live_raw, vod_raw, series_raw = await asyncio.gather( client.get_live_categories(), client.get_vod_categories(), client.get_series_categories(), return_exceptions=True, ) except Exception as e: raise HTTPException(500, f"Failed to fetch provider categories: {e}") db_cats = await db.execute( select(Category.provider_category_id, Category.type) .where(Category.provider_account_id == provider_id) ) synced: set[tuple] = {(str(r.provider_category_id), r.type) for r in db_cats} dismissed_result = await db.execute( select(NotificationDismissal.category_id_str, NotificationDismissal.category_type) .where(NotificationDismissal.provider_id == provider_id) ) already_dismissed: set[tuple] = {(r[0], r[1]) for r in dismissed_result.all()} count = 0 for raw, ctype in [(live_raw, ContentType.live), (vod_raw, ContentType.movie), (series_raw, ContentType.series)]: if isinstance(raw, Exception): continue for c in (raw if isinstance(raw, list) else []): cat_id = str(c.get("category_id", "")) if not cat_id or (cat_id, ctype) in synced: continue if (cat_id, ctype.value) not in already_dismissed: db.add(NotificationDismissal( provider_id=provider_id, category_id_str=cat_id, category_type=ctype.value, )) count += 1 await db.commit() return {"ok": True, "dismissed": count} @router.get("/{provider_id}/epg-url") async def get_provider_epg_url(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): """Returns the XMLTV EPG URL for this provider (auto-registered as EPG source).""" result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) provider = result.scalar_one_or_none() if not provider: raise HTTPException(404, "Provider not found") url = XtreamClient(provider.base_url, provider.username, provider.password).get_xmltv_url() return {"url": url} @router.post("/{provider_id}/test") async def test_provider(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) provider = result.scalar_one_or_none() if not provider: raise HTTPException(404, "Provider not found") client = XtreamClient(provider.base_url, provider.username, provider.password) ok = False try: info = await client.get_user_info() user_info = info.get("user_info", {}) ok = user_info.get("status") == "Active" exp_ts = user_info.get("exp_date") if exp_ts: try: provider.expiry_date = datetime.fromtimestamp(int(exp_ts), tz=timezone.utc) await db.commit() except (ValueError, TypeError): pass except Exception as e: logger.warning(f"Provider test failed: {e}") return {"connected": ok} @router.get("/{provider_id}/sync/categories") async def get_provider_categories(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): """Fetch available categories from provider without importing — used for the category picker.""" result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) provider = result.scalar_one_or_none() if not provider: raise HTTPException(404, "Provider not found") client = XtreamClient(provider.base_url, provider.username, provider.password) live_task = asyncio.create_task(client.get_live_categories()) vod_task = asyncio.create_task(client.get_vod_categories()) series_task = asyncio.create_task(client.get_series_categories()) live_raw, vod_raw, series_raw = await asyncio.gather(live_task, vod_task, series_task, return_exceptions=True) def to_list(raw): if isinstance(raw, Exception): logger.warning(f"Category fetch error: {raw}") return [] return [{"id": str(c.get("category_id", "")), "name": c.get("category_name", "")} for c in raw if c.get("category_id")] return { "live": to_list(live_raw), "movie": to_list(vod_raw), "series": to_list(series_raw), } @router.get("/{provider_id}/sync/status") async def sync_status(provider_id: int, _=Depends(get_current_admin)): return _sync_status.get(provider_id, {"status": "idle"}) @router.post("/{provider_id}/sync") async def sync_provider( provider_id: int, body: SyncRequest = SyncRequest(), db: AsyncSession = Depends(get_db), _=Depends(get_current_admin), ): result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) provider = result.scalar_one_or_none() if not provider: raise HTTPException(404, "Provider not found") if _sync_status.get(provider_id, {}).get("status") == "running": return {"ok": False, "message": "Sync already in progress", "status": _sync_status[provider_id]} _sync_status[provider_id] = {"status": "running", "progress": "Iniciando...", "stats": {}} asyncio.create_task(_run_sync( provider_id, provider.base_url, provider.username, provider.password, selected_live=set(body.live_category_ids) if body.live_category_ids is not None else None, selected_movie=set(body.movie_category_ids) if body.movie_category_ids is not None else None, selected_series=set(body.series_category_ids) if body.series_category_ids is not None else None, )) return {"ok": True, "message": "Sync started in background", "status_url": f"/api/admin/providers/{provider_id}/sync/status"} async def _run_sync( provider_id: int, base_url: str, username: str, password: str, selected_live: set[str] | None = None, selected_movie: set[str] | None = None, selected_series: set[str] | None = None, ) -> None: """ Efficient sync: fetches ALL streams in one call per type (not per category). Total HTTP calls: 3 category lists + 3 stream lists = 6 calls regardless of provider size. selected_* = None means import all; set means only import those category IDs. """ client = XtreamClient(base_url, username, password) stats = { "live_categories": 0, "live_channels": 0, "vod_categories": 0, "vod_channels": 0, "series_categories": 0, "series": 0, } def update_progress(msg: str): _sync_status[provider_id] = {"status": "running", "progress": msg, "stats": stats.copy()} logger.info(f"[Sync #{provider_id}] {msg}") try: async with AsyncSessionLocal() as db: # ── LIVE ────────────────────────────────────────────────── update_progress("Descargando categorías Live...") try: live_cats = await client.get_live_categories() cat_map_live: dict[str, Category] = {} for cat_data in live_cats: cat_id_str = str(cat_data.get("category_id", "")) if selected_live is not None and cat_id_str not in selected_live: continue cat = await _upsert_category(db, cat_data, ContentType.live, provider_id) cat_map_live[cat_id_str] = cat stats["live_categories"] += 1 await db.flush() update_progress(f"Descargando canales Live ({stats['live_categories']} cats)...") all_live = await client.get_live_streams() for ch_data in all_live: cat_id_str = str(ch_data.get("category_id", "")) if cat_id_str not in cat_map_live: continue # category not selected or not loaded cat = cat_map_live.get(cat_id_str) await _upsert_channel(db, ch_data, cat, ContentType.live, provider_id, client) stats["live_channels"] += 1 if stats["live_channels"] % 500 == 0: update_progress(f"Live: {stats['live_channels']} canales procesados...") await db.flush() await db.flush() except Exception as e: logger.error(f"[Sync #{provider_id}] Live error: {e}", exc_info=True) # ── VOD ─────────────────────────────────────────────────── update_progress("Descargando categorías VOD...") try: vod_cats = await client.get_vod_categories() cat_map_vod: dict[str, Category] = {} for cat_data in vod_cats: cat_id_str = str(cat_data.get("category_id", "")) if selected_movie is not None and cat_id_str not in selected_movie: continue cat = await _upsert_category(db, cat_data, ContentType.movie, provider_id) cat_map_vod[cat_id_str] = cat stats["vod_categories"] += 1 await db.flush() update_progress(f"Descargando películas VOD ({stats['vod_categories']} cats)...") all_vod = await client.get_vod_streams() for ch_data in all_vod: cat_id_str = str(ch_data.get("category_id", "")) if cat_id_str not in cat_map_vod: continue cat = cat_map_vod.get(cat_id_str) await _upsert_channel(db, ch_data, cat, ContentType.movie, provider_id, client) stats["vod_channels"] += 1 if stats["vod_channels"] % 500 == 0: update_progress(f"VOD: {stats['vod_channels']} canales procesados...") await db.flush() await db.flush() except Exception as e: logger.error(f"[Sync #{provider_id}] VOD error: {e}", exc_info=True) # ── SERIES ──────────────────────────────────────────────── update_progress("Descargando categorías Series...") try: series_cats = await client.get_series_categories() cat_map_series: dict[str, Category] = {} for cat_data in series_cats: cat_id_str = str(cat_data.get("category_id", "")) if selected_series is not None and cat_id_str not in selected_series: continue cat = await _upsert_category(db, cat_data, ContentType.series, provider_id) cat_map_series[cat_id_str] = cat stats["series_categories"] += 1 await db.flush() update_progress(f"Descargando series ({stats['series_categories']} cats)...") all_series = await client.get_series() for s_data in all_series: cat_id_str = str(s_data.get("category_id", "")) if cat_id_str not in cat_map_series: continue cat = cat_map_series.get(cat_id_str) await _upsert_channel(db, s_data, cat, ContentType.series, provider_id, client) stats["series"] += 1 if stats["series"] % 500 == 0: update_progress(f"Series: {stats['series']} procesadas...") await db.flush() await db.flush() except Exception as e: logger.error(f"[Sync #{provider_id}] Series error: {e}", exc_info=True) # ── FINAL COMMIT ────────────────────────────────────────── update_progress("Guardando en base de datos...") result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) provider = result.scalar_one_or_none() if provider: provider.last_sync_at = datetime.now(timezone.utc) provider.last_sync_categories = json.dumps({ "live": list(selected_live) if selected_live is not None else None, "movie": list(selected_movie) if selected_movie is not None else None, "series": list(selected_series) if selected_series is not None else None, }) await db.commit() _sync_status[provider_id] = {"status": "done", "progress": "Sync completado", "stats": stats} logger.info(f"[Sync #{provider_id}] Completed: {stats}") # Auto-update EPG source and trigger background refresh async with AsyncSessionLocal() as epg_db: prov_result = await epg_db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) prov = prov_result.scalar_one_or_none() if prov: await _sync_epg_source(epg_db, prov) await refresh_epg(epg_db) except Exception as e: logger.error(f"[Sync #{provider_id}] Fatal error: {e}", exc_info=True) _sync_status[provider_id] = {"status": "error", "progress": str(e), "stats": stats} # ── Provider URL management ──────────────────────────────────────────── class ProviderUrlCreate(BaseModel): url: str priority: int = 10 is_active: bool = True @router.get("/{provider_id}/urls", response_model=list[ProviderUrlOut]) async def list_provider_urls(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): result = await db.execute( select(ProviderUrl) .where(ProviderUrl.provider_account_id == provider_id) .order_by(ProviderUrl.priority) ) return result.scalars().all() @router.post("/{provider_id}/urls", response_model=ProviderUrlOut) async def add_provider_url(provider_id: int, data: ProviderUrlCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): pu = ProviderUrl(provider_account_id=provider_id, **data.model_dump()) db.add(pu) await db.commit() await db.refresh(pu) return pu @router.put("/{provider_id}/urls/{url_id}", response_model=ProviderUrlOut) async def update_provider_url(provider_id: int, url_id: int, data: ProviderUrlCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): result = await db.execute( select(ProviderUrl).where(ProviderUrl.id == url_id, ProviderUrl.provider_account_id == provider_id) ) pu = result.scalar_one_or_none() if not pu: raise HTTPException(404, "URL not found") pu.url = data.url pu.priority = data.priority pu.is_active = data.is_active await db.commit() await db.refresh(pu) return pu @router.delete("/{provider_id}/urls/{url_id}") async def delete_provider_url(provider_id: int, url_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): result = await db.execute( select(ProviderUrl).where(ProviderUrl.id == url_id, ProviderUrl.provider_account_id == provider_id) ) pu = result.scalar_one_or_none() if not pu: raise HTTPException(404, "URL not found") await db.delete(pu) await db.commit() return {"ok": True} @router.post("/{provider_id}/urls/check-all") async def check_all_provider_urls(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): from ...core.health_checker import check_provider_urls result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) provider = result.scalar_one_or_none() if not provider: raise HTTPException(404, "Provider not found") results = await check_provider_urls(provider_id) return results async def _upsert_category(db: AsyncSession, cat_data: dict, ctype: ContentType, provider_id: int) -> Category: provider_cat_id = str(cat_data.get("category_id", "")) cat_name = cat_data.get("category_name", "Unknown") # First: check if a category with the same name+type already exists (any provider) # This merges duplicate categories from multiple providers automatically result = await db.execute( select(Category).where( Category.name == cat_name, Category.type == ctype, ) ) cat = result.scalar_one_or_none() if not cat: # Check by provider+id (in case name changed) result2 = await db.execute( select(Category).where( Category.provider_category_id == provider_cat_id, Category.provider_account_id == provider_id, Category.type == ctype, ) ) cat = result2.scalar_one_or_none() if not cat: cat = Category( name=cat_name, type=ctype, provider_category_id=provider_cat_id, provider_account_id=provider_id, ) db.add(cat) await db.flush() else: cat.name = cat_name return cat async def _upsert_channel( db: AsyncSession, ch_data: dict, category: Category | None, ctype: ContentType, provider_id: int, client: XtreamClient, ) -> Channel | None: stream_id = str(ch_data.get("stream_id") or ch_data.get("series_id", "")) if not stream_id: return None result = await db.execute( select(Channel).where( Channel.stream_id_at_provider == stream_id, Channel.type == ctype, ) ) channel = result.scalar_one_or_none() name = ch_data.get("name") or ch_data.get("title", "Unknown") or "Unknown" tvg_id = ch_data.get("epg_channel_id") or ch_data.get("tvg_id") or None logo = ch_data.get("stream_icon") or ch_data.get("cover") or None # Truncate logo to avoid DB column limit (base64 images from some providers) if logo and len(logo) > 900: logo = None cat_id = category.id if category else None if not channel: channel = Channel( name=name, tvg_id=tvg_id, tvg_logo=logo, stream_id_at_provider=stream_id, category_id=cat_id, type=ctype, ) db.add(channel) await db.flush() else: channel.name = name channel.tvg_id = tvg_id channel.tvg_logo = logo # Only update category if channel has none yet (first-provider-wins prevents # cross-provider syncs from breaking existing catalog assignments) if cat_id and not channel.category_id: channel.category_id = cat_id # Build stream URL container_ext = ch_data.get("container_extension", "ts") or "ts" if ctype == ContentType.live: url = client.build_live_stream_url(stream_id) elif ctype == ContentType.movie: url = client.build_vod_stream_url(stream_id, container_ext) else: url = client.build_series_stream_url(stream_id, container_ext) # Upsert provider map result = await db.execute( select(ChannelProviderMap).where( ChannelProviderMap.channel_id == channel.id, ChannelProviderMap.provider_account_id == provider_id, ) ) pmap = result.scalar_one_or_none() if not pmap: pmap = ChannelProviderMap( channel_id=channel.id, provider_account_id=provider_id, stream_url=url, ) db.add(pmap) else: pmap.stream_url = url return channel