diff --git a/VERSION b/VERSION index 3d9dcb1b..4e8f395f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.25.3 +0.26.0 diff --git a/backend/app/api/main.py b/backend/app/api/main.py index ad8883e2..6860a914 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -14,6 +14,7 @@ from app.api.v1 import ( visualization, bgp, system_control, + tv, ) api_router = APIRouter() @@ -33,3 +34,4 @@ api_router.include_router(settings.router, prefix="/settings", tags=["settings"] api_router.include_router(system_control.router, prefix="/system", tags=["system"]) api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"]) api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"]) +api_router.include_router(tv.router, prefix="/tv", tags=["tv"]) diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 9b78ec54..ff580a20 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -1,3 +1,4 @@ +from copy import deepcopy from datetime import UTC, datetime from typing import Optional @@ -13,6 +14,7 @@ from app.models.datasource import DataSource from app.models.system_setting import SystemSetting from app.models.user import User from app.services.scheduler import sync_datasource_job +from app.services.tv_streams import DEFAULT_TV_SETTINGS, get_tv_settings_payload, normalize_tv_settings router = APIRouter() @@ -36,6 +38,7 @@ DEFAULT_SETTINGS = { "max_login_attempts": 5, "password_policy": "medium", }, + "tv": DEFAULT_TV_SETTINGS, } @@ -67,8 +70,34 @@ class CollectorSettingsUpdate(BaseModel): frequency_minutes: int = Field(default=60, ge=1, le=10080) +class TVStreamSourceUpdate(BaseModel): + id: str = Field(min_length=1, max_length=100) + name: str = Field(min_length=1, max_length=200) + provider: str = Field(default="Unknown", max_length=100) + region: str = Field(default="Global", max_length=100) + language: str = Field(default="und", max_length=32) + source_type: str = Field(default="iframe", pattern="^(iframe|hls|video|external|youtube)$") + embed_url: str = "" + stream_url: str = "" + homepage_url: str = "" + poster_url: str = "" + youtube_video_id: str = "" + youtube_channel: str = "" + is_enabled: bool = True + is_fallback: bool = False + sort_order: int = Field(default=10, ge=0, le=9999) + collector_source: Optional[str] = None + notes: str = "" + + +class TVSettingsUpdate(BaseModel): + default_source_id: str = Field(default=DEFAULT_TV_SETTINGS["default_source_id"], min_length=1) + auto_fallback: bool = True + sources: list[TVStreamSourceUpdate] = Field(default_factory=list) + + def merge_with_defaults(category: str, payload: Optional[dict]) -> dict: - merged = DEFAULT_SETTINGS[category].copy() + merged = deepcopy(DEFAULT_SETTINGS[category]) if payload: merged.update(payload) return merged @@ -195,6 +224,25 @@ async def update_security_settings( return {"status": "updated", "security": payload} +@router.get("/tv") +async def get_tv_settings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return {"tv": await get_tv_settings_payload(db)} + + +@router.put("/tv") +async def update_tv_settings( + settings: TVSettingsUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + payload = normalize_tv_settings(settings.model_dump()) + saved = await save_setting_payload(db, "tv", payload) + return {"status": "updated", "tv": normalize_tv_settings(saved)} + + @router.get("/collectors") async def get_collector_settings( current_user: User = Depends(get_current_user), @@ -240,6 +288,7 @@ async def get_all_settings( "system": setting_payloads["system"], "notifications": setting_payloads["notifications"], "security": setting_payloads["security"], + "tv": await get_tv_settings_payload(db), "collectors": [serialize_collector(datasource) for datasource in datasources], "generated_at": to_iso8601_utc(datetime.now(UTC)), } diff --git a/backend/app/api/v1/tv.py b/backend/app/api/v1/tv.py new file mode 100644 index 00000000..75f01ea5 --- /dev/null +++ b/backend/app/api/v1/tv.py @@ -0,0 +1,70 @@ +from urllib.parse import quote, urljoin + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import get_db +from app.services.tv_streams import get_public_tv_payload, is_allowed_tv_proxy_url + +router = APIRouter() + + +@router.get("/streams") +async def list_public_tv_streams( + db: AsyncSession = Depends(get_db), +): + return await get_public_tv_payload(db) + + +@router.get("/proxy") +async def proxy_tv_stream( + url: str = Query(..., description="Upstream TV stream or manifest URL"), + db: AsyncSession = Depends(get_db), +): + payload = await get_public_tv_payload(db) + if not is_allowed_tv_proxy_url(url, payload.get("sources", [])): + raise HTTPException(status_code=403, detail="TV proxy target is not allowed") + + try: + async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as client: + upstream = await client.get( + url, + headers={ + "User-Agent": "Mozilla/5.0", + "Referer": "https://tv.cctv.com/live/cctv4/", + }, + ) + upstream.raise_for_status() + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"Failed to fetch TV stream: {exc}") from exc + + content_type = upstream.headers.get("content-type", "application/octet-stream") + raw_content = upstream.content + response_url = str(upstream.url) + is_manifest = ( + response_url.endswith(".m3u8") + or "mpegurl" in content_type.lower() + or raw_content.lstrip().startswith(b"#EXTM3U") + ) + + headers = {"Cache-Control": "no-store"} + + if is_manifest: + manifest_text = upstream.text + rewritten_lines: list[str] = [] + for line in manifest_text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + rewritten_lines.append(line) + continue + absolute_url = urljoin(response_url, stripped) + rewritten_lines.append(f"/api/v1/tv/proxy?url={quote(absolute_url, safe='')}") + return Response( + content="\n".join(rewritten_lines), + media_type="application/vnd.apple.mpegurl", + headers=headers, + ) + + return Response(content=raw_content, media_type=content_type, headers=headers) diff --git a/backend/app/core/datasource_defaults.py b/backend/app/core/datasource_defaults.py index 189030ee..b1fb18f7 100644 --- a/backend/app/core/datasource_defaults.py +++ b/backend/app/core/datasource_defaults.py @@ -155,6 +155,13 @@ DEFAULT_DATASOURCES = { "priority": "P1", "frequency_minutes": 1440, }, + "news_live_streams": { + "id": 26, + "name": "News Live Streams", + "module": "L4", + "priority": "P2", + "frequency_minutes": 720, + }, } ID_TO_COLLECTOR = {info["id"]: name for name, info in DEFAULT_DATASOURCES.items()} diff --git a/backend/app/services/collectors/__init__.py b/backend/app/services/collectors/__init__.py index fdc4d4a7..add854b0 100644 --- a/backend/app/services/collectors/__init__.py +++ b/backend/app/services/collectors/__init__.py @@ -35,6 +35,7 @@ from app.services.collectors.bgpstream import BGPStreamBackfillCollector from app.services.collectors.iptoasn import IPtoASNPrefixGeoCollector from app.services.collectors.opengeofeed import OpenGeoFeedPrefixGeoCollector from app.services.collectors.nro_delegated import NRODelegatedPrefixGeoCollector +from app.services.collectors.news_live_streams import NewsLiveStreamsCollector collector_registry.register(TOP500Collector()) collector_registry.register(EpochAIGPUCollector()) @@ -61,3 +62,4 @@ collector_registry.register(BGPStreamBackfillCollector()) collector_registry.register(IPtoASNPrefixGeoCollector()) collector_registry.register(OpenGeoFeedPrefixGeoCollector()) collector_registry.register(NRODelegatedPrefixGeoCollector()) +collector_registry.register(NewsLiveStreamsCollector()) diff --git a/backend/app/services/collectors/news_live_streams.py b/backend/app/services/collectors/news_live_streams.py new file mode 100644 index 00000000..84644605 --- /dev/null +++ b/backend/app/services/collectors/news_live_streams.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +import httpx + +from app.services.collectors.base import BaseCollector + + +class NewsLiveStreamsCollector(BaseCollector): + """Collect normalized news live-stream sources from a JSON endpoint.""" + + name = "news_live_streams" + priority = "P2" + module = "L4" + frequency_hours = 12 + data_type = "news_live_stream" + fail_on_empty = False + + async def fetch(self) -> list[dict[str, Any]]: + request_url = (self._resolved_url or "").strip() + if not request_url: + return [] + + async with httpx.AsyncClient(timeout=45.0, follow_redirects=True) as client: + response = await client.get( + request_url, + headers={ + "User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)", + "Accept": "application/json", + }, + ) + response.raise_for_status() + return self.parse_response(response.json()) + + def parse_response(self, response: Any) -> list[dict[str, Any]]: + if isinstance(response, dict): + candidates = response.get("sources") or response.get("streams") or response.get("data") or [] + elif isinstance(response, list): + candidates = response + else: + candidates = [] + + normalized: list[dict[str, Any]] = [] + for index, item in enumerate(candidates): + if not isinstance(item, dict): + continue + + stream_id = item.get("id") or item.get("source_id") or item.get("slug") or f"news-live-{index + 1}" + name = str(item.get("name") or item.get("title") or f"News Live {index + 1}").strip() + if not name: + continue + + metadata = { + "provider": item.get("provider") or item.get("publisher") or "Collector", + "region": item.get("region") or item.get("country") or "Global", + "language": item.get("language") or "und", + "source_type": item.get("source_type") or "iframe", + "embed_url": item.get("embed_url") or item.get("url") or "", + "stream_url": item.get("stream_url") or "", + "homepage_url": item.get("homepage_url") or item.get("source_url") or "", + "poster_url": item.get("poster_url") or "", + "sort_order": item.get("sort_order", 200 + index), + "notes": item.get("notes") or item.get("description") or "", + "is_enabled": item.get("is_enabled", True), + } + + normalized.append( + { + "source_id": str(stream_id), + "name": name, + "description": metadata["notes"], + "metadata": metadata, + "reference_date": item.get("reference_date", datetime.now(UTC).isoformat()), + } + ) + + return normalized diff --git a/backend/app/services/tv_streams.py b/backend/app/services/tv_streams.py new file mode 100644 index 00000000..d5fcd7c5 --- /dev/null +++ b/backend/app/services/tv_streams.py @@ -0,0 +1,466 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any +from urllib.parse import urlparse + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.time import to_iso8601_utc +from app.models.collected_data import CollectedData +from app.models.system_setting import SystemSetting + +DEFAULT_TV_SOURCE_ID = "cgtn-en" +TV_SETTINGS_CATEGORY = "tv" +TV_LIVE_SOURCE_COLLECTOR = "news_live_streams" +TV_LIVE_SOURCE_DATA_TYPE = "news_live_stream" + +DEFAULT_TV_SETTINGS = { + "default_source_id": DEFAULT_TV_SOURCE_ID, + "auto_fallback": True, + "sources": [ + { + "id": DEFAULT_TV_SOURCE_ID, + "name": "CCTV-4 中文国际", + "provider": "CCTV", + "region": "China", + "language": "zh-CN", + "source_type": "hls", + "embed_url": "https://tv.cctv.com/live/cctv4/", + "stream_url": "https://ldocctvwbcdtxy.liveplay.myqcloud.com/ldocctvwbcd/cdrmldcctv4_1_td.m3u8", + "homepage_url": "https://tv.cctv.com/live/cctv4/", + "poster_url": "", + "is_enabled": True, + "is_fallback": True, + "sort_order": 10, + "collector_source": None, + "notes": "默认兜底新闻直播源。优先尝试 CCTV-4 官方 HLS 播放流,若直播放失败则回退到央视官网直播页。", + }, + { + "id": "reuters-tv", + "name": "Reuters TV", + "provider": "Reuters", + "region": "Global", + "language": "en", + "source_type": "hls", + "embed_url": "", + "stream_url": "https://reuters-reutersnow-1-eu.rakuten.wurl.tv/playlist.m3u8", + "homepage_url": "https://www.reuters.com/video/live/", + "poster_url": "", + "is_enabled": True, + "is_fallback": False, + "sort_order": 20, + "collector_source": None, + "notes": "参考 worldmonitor 的默认新闻频道清单,优先作为全球英文新闻直播放源。", + }, + { + "id": "cgtn-en", + "name": "CGTN English", + "provider": "CGTN", + "region": "Global", + "language": "en", + "source_type": "youtube", + "embed_url": "https://www.youtube.com/watch?v=BOy2xDU1LC8", + "stream_url": "https://news.cgtn.com/resource/live/english/cgtn-news.m3u8", + "youtube_video_id": "BOy2xDU1LC8", + "homepage_url": "https://news.cgtn.com/", + "poster_url": "", + "is_enabled": True, + "is_fallback": False, + "sort_order": 30, + "collector_source": None, + "notes": "优先使用官方 YouTube 直播源,保留 HLS 直播放流作为候选信息。", + }, + { + "id": "cgtn-es", + "name": "CGTN Espanol", + "provider": "CGTN", + "region": "Latin America", + "language": "es", + "source_type": "hls", + "embed_url": "", + "stream_url": "https://news.cgtn.com/resource/live/espanol/cgtn-e.m3u8", + "homepage_url": "https://news.cgtn.com/", + "poster_url": "", + "is_enabled": True, + "is_fallback": False, + "sort_order": 40, + "collector_source": None, + "notes": "西语国际新闻频道,覆盖拉美方向态势。", + }, + { + "id": "dw-espanol", + "name": "DW Espanol", + "provider": "Deutsche Welle", + "region": "Europe", + "language": "es", + "source_type": "hls", + "embed_url": "", + "stream_url": "https://dwamdstream104.akamaized.net/hls/live/2015530/dwstream104/stream04/streamPlaylist.m3u8", + "homepage_url": "https://www.dw.com/es/", + "poster_url": "", + "is_enabled": True, + "is_fallback": False, + "sort_order": 50, + "collector_source": None, + "notes": "来自 worldmonitor 可选频道清单的直播放源。", + }, + { + "id": "dw-arabic", + "name": "DW Arabic", + "provider": "Deutsche Welle", + "region": "Middle East", + "language": "ar", + "source_type": "hls", + "embed_url": "", + "stream_url": "https://dwamdstream103.akamaized.net/hls/live/2015526/dwstream103/index.m3u8", + "homepage_url": "https://www.dw.com/ar/", + "poster_url": "", + "is_enabled": True, + "is_fallback": False, + "sort_order": 60, + "collector_source": None, + "notes": "阿拉伯语新闻流,适合作为中东方向新闻补充源。", + }, + { + "id": "aljazeera-mubasher", + "name": "Al Jazeera Mubasher", + "provider": "Al Jazeera", + "region": "Middle East", + "language": "ar", + "source_type": "hls", + "embed_url": "", + "stream_url": "https://live-hls-web-ajm.getaj.net/AJM/index.m3u8", + "homepage_url": "https://www.aljazeera.net/live", + "poster_url": "", + "is_enabled": True, + "is_fallback": False, + "sort_order": 70, + "collector_source": None, + "notes": "中东实时新闻流,来自 worldmonitor HLS 频道目录。", + }, + { + "id": "arirang-news", + "name": "Arirang News", + "provider": "Arirang", + "region": "Korea", + "language": "en", + "source_type": "hls", + "embed_url": "", + "stream_url": "https://amdlive-ch01-ctnd-com.akamaized.net/arirang_1ch/smil:arirang_1ch.smil/playlist.m3u8", + "homepage_url": "https://www.arirang.com/", + "poster_url": "", + "is_enabled": True, + "is_fallback": False, + "sort_order": 80, + "collector_source": None, + "notes": "东北亚英语新闻源,适合补充韩半岛与东亚视角。", + }, + { + "id": "abp-news", + "name": "ABP News", + "provider": "ABP", + "region": "India", + "language": "hi", + "source_type": "hls", + "embed_url": "", + "stream_url": "https://abplivetv.pc.cdn.bitgravity.com/httppush/abp_livetv/abp_abpnews/master.m3u8", + "homepage_url": "https://news.abplive.com/live-tv", + "poster_url": "", + "is_enabled": True, + "is_fallback": False, + "sort_order": 90, + "collector_source": None, + "notes": "印度新闻直播放源,补充南亚区域视角。", + }, + { + "id": "sabc-news", + "name": "SABC News", + "provider": "SABC", + "region": "Africa", + "language": "en", + "source_type": "hls", + "embed_url": "", + "stream_url": "https://sabconetanw.cdn.mangomolo.com/news/smil:news.stream.smil/playlist.m3u8", + "homepage_url": "https://www.sabcnews.com/sabcnews/", + "poster_url": "", + "is_enabled": True, + "is_fallback": False, + "sort_order": 100, + "collector_source": None, + "notes": "非洲英语新闻源,补充非洲区域新闻覆盖。", + }, + ], +} + + +def _clean_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def _clean_url(value: Any) -> str: + text = _clean_text(value) + if not text: + return "" + + parsed = urlparse(text) + if parsed.scheme and parsed.scheme not in {"http", "https"}: + return "" + if parsed.scheme and not parsed.netloc: + return "" + return text + + +def _clean_bool(value: Any, *, default: bool) -> bool: + if isinstance(value, bool): + return value + if value in (None, ""): + return default + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + return bool(value) + + +def _clean_int(value: Any, *, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def normalize_tv_source(source: dict[str, Any] | None, *, index: int = 0) -> dict[str, Any]: + payload = dict(source or {}) + source_id = _clean_text(payload.get("id")) or f"tv-source-{index + 1}" + source_type = _clean_text(payload.get("source_type")).lower() + youtube_video_id = _clean_text(payload.get("youtube_video_id")) + youtube_channel = _clean_text(payload.get("youtube_channel")) + if source_type not in {"iframe", "hls", "video", "external", "youtube"}: + if youtube_video_id or youtube_channel: + source_type = "youtube" + else: + source_type = "iframe" if _clean_text(payload.get("embed_url")) else "external" + + if source_type == "youtube" and not youtube_video_id and not youtube_channel: + source_type = "iframe" if _clean_text(payload.get("embed_url")) else "external" + + return { + "id": source_id, + "name": _clean_text(payload.get("name")) or f"新闻直播源 {index + 1}", + "provider": _clean_text(payload.get("provider")) or "Unknown", + "region": _clean_text(payload.get("region")) or "Global", + "language": _clean_text(payload.get("language")) or "und", + "source_type": source_type, + "embed_url": _clean_url(payload.get("embed_url")), + "stream_url": _clean_url(payload.get("stream_url")), + "homepage_url": _clean_url(payload.get("homepage_url")), + "poster_url": _clean_url(payload.get("poster_url")), + "youtube_video_id": youtube_video_id, + "youtube_channel": youtube_channel, + "is_enabled": _clean_bool(payload.get("is_enabled"), default=True), + "is_fallback": _clean_bool(payload.get("is_fallback"), default=False), + "sort_order": _clean_int(payload.get("sort_order"), default=(index + 1) * 10), + "collector_source": payload.get("collector_source"), + "notes": _clean_text(payload.get("notes")), + "updated_at": _clean_text(payload.get("updated_at")), + } + + +def normalize_tv_settings(payload: dict[str, Any] | None) -> dict[str, Any]: + merged = { + "default_source_id": DEFAULT_TV_SETTINGS["default_source_id"], + "auto_fallback": DEFAULT_TV_SETTINGS["auto_fallback"], + "sources": [], + } + + raw_sources = [] + if isinstance(payload, dict): + merged["default_source_id"] = ( + _clean_text(payload.get("default_source_id")) or merged["default_source_id"] + ) + merged["auto_fallback"] = _clean_bool( + payload.get("auto_fallback"), + default=DEFAULT_TV_SETTINGS["auto_fallback"], + ) + if isinstance(payload.get("sources"), list): + raw_sources = payload["sources"] + + if not raw_sources: + raw_sources = DEFAULT_TV_SETTINGS["sources"] + + normalized_sources = [ + normalize_tv_source(source, index=index) + for index, source in enumerate(raw_sources) + ] + + if not any(source["id"] == DEFAULT_TV_SOURCE_ID for source in normalized_sources): + normalized_sources.append( + normalize_tv_source(DEFAULT_TV_SETTINGS["sources"][0], index=len(normalized_sources)) + ) + + default_source_exists = any( + source["id"] == merged["default_source_id"] and source["is_enabled"] + for source in normalized_sources + ) + if not default_source_exists: + fallback_source = next( + (source for source in normalized_sources if source["is_fallback"] and source["is_enabled"]), + None, + ) + first_enabled_source = next( + (source for source in normalized_sources if source["is_enabled"]), + None, + ) + merged["default_source_id"] = ( + fallback_source["id"] + if fallback_source + else first_enabled_source["id"] + if first_enabled_source + else DEFAULT_TV_SOURCE_ID + ) + + merged["sources"] = sorted( + normalized_sources, + key=lambda item: (item["sort_order"], item["name"], item["id"]), + ) + return merged + + +async def get_tv_settings_payload(db: AsyncSession) -> dict[str, Any]: + result = await db.execute( + select(SystemSetting).where(SystemSetting.category == TV_SETTINGS_CATEGORY) + ) + record = result.scalar_one_or_none() + payload = record.payload if record else None + return normalize_tv_settings(payload) + + +def _build_collected_tv_source(record: CollectedData, index: int) -> dict[str, Any]: + metadata = dict(record.extra_data or {}) + return normalize_tv_source( + { + "id": metadata.get("id") or record.source_id or record.entity_key, + "name": record.name or record.title or metadata.get("name") or f"采集直播源 {index + 1}", + "provider": metadata.get("provider") or metadata.get("publisher") or "Collector", + "region": metadata.get("region") or metadata.get("country") or "Global", + "language": metadata.get("language") or "und", + "source_type": metadata.get("source_type") or "iframe", + "embed_url": metadata.get("embed_url") or metadata.get("url") or "", + "stream_url": metadata.get("stream_url") or "", + "homepage_url": metadata.get("homepage_url") or metadata.get("source_url") or "", + "poster_url": metadata.get("poster_url") or "", + "youtube_video_id": metadata.get("youtube_video_id") or metadata.get("video_id") or "", + "youtube_channel": metadata.get("youtube_channel") or metadata.get("channel_handle") or "", + "is_enabled": metadata.get("is_enabled", True), + "is_fallback": False, + "sort_order": metadata.get("sort_order", 200 + index), + "collector_source": record.source, + "notes": record.description or metadata.get("notes") or "", + "updated_at": to_iso8601_utc(record.updated_at or record.reference_date or datetime.now(UTC)), + }, + index=index, + ) + + +async def get_collected_tv_sources(db: AsyncSession) -> list[dict[str, Any]]: + result = await db.execute( + select(CollectedData) + .where(CollectedData.source == TV_LIVE_SOURCE_COLLECTOR) + .where(CollectedData.data_type == TV_LIVE_SOURCE_DATA_TYPE) + .where(CollectedData.is_current.is_(True)) + .where(CollectedData.is_valid == 1) + .order_by(CollectedData.reference_date.desc().nullslast(), CollectedData.id.desc()) + ) + rows = result.scalars().all() + return [_build_collected_tv_source(record, index) for index, record in enumerate(rows)] + + +def build_public_tv_payload( + settings_payload: dict[str, Any], + collected_sources: list[dict[str, Any]], +) -> dict[str, Any]: + configured_sources = [ + source for source in settings_payload["sources"] if source["is_enabled"] + ] + + merged_by_id = {source["id"]: source for source in configured_sources} + for source in collected_sources: + if source["id"] in merged_by_id or not source["is_enabled"]: + continue + merged_by_id[source["id"]] = source + + available_sources = sorted( + merged_by_id.values(), + key=lambda item: (item["sort_order"], item["name"], item["id"]), + ) + + default_source = next( + ( + source + for source in available_sources + if source["id"] == settings_payload["default_source_id"] + ), + None, + ) + fallback_source = next( + (source for source in available_sources if source["is_fallback"]), + None, + ) + + resolved_source = default_source or fallback_source or (available_sources[0] if available_sources else None) + latest_updated_at = max( + (source.get("updated_at") or "" for source in available_sources), + default="", + ) + + return { + "default_source_id": settings_payload["default_source_id"], + "auto_fallback": settings_payload["auto_fallback"], + "selected_source": resolved_source, + "fallback_source": fallback_source, + "sources": available_sources, + "source_count": len(available_sources), + "latest_updated_at": latest_updated_at or to_iso8601_utc(datetime.now(UTC)), + "generated_at": to_iso8601_utc(datetime.now(UTC)), + } + + +async def get_public_tv_payload(db: AsyncSession) -> dict[str, Any]: + settings_payload = await get_tv_settings_payload(db) + collected_sources = await get_collected_tv_sources(db) + return build_public_tv_payload(settings_payload, collected_sources) + + +def _extract_allowed_tv_hosts(sources: list[dict[str, Any]]) -> set[str]: + hosts: set[str] = set() + for source in sources: + for field in ("stream_url", "embed_url", "homepage_url", "youtube_channel"): + value = _clean_url(source.get(field)) + if not value: + continue + parsed = urlparse(value) + if parsed.hostname: + hosts.add(parsed.hostname.lower()) + return hosts + + +def is_allowed_tv_proxy_url(url: str, sources: list[dict[str, Any]]) -> bool: + cleaned = _clean_url(url) + if not cleaned: + return False + + parsed = urlparse(cleaned) + hostname = (parsed.hostname or "").lower() + if not hostname: + return False + + allowed_hosts = _extract_allowed_tv_hosts(sources) + if hostname in allowed_hosts: + return True + return any(hostname.endswith(f".{allowed_host}") for allowed_host in allowed_hosts) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 849dad95..0b6b22ce 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -29,6 +29,30 @@ Released: 2026-04-11 - Fixed [frontend/public/earth/css/info-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/info-panel.css) so the Earth brand area now uses consistent `earth-brand` component selectors and English-specific typography hooks, avoiding the earlier one-off `brand-banner` naming drift and duplicated title styles. +## 0.26.0 + +Released: 2026-04-12 + +### Highlights + +- Added an operator-facing TV live module to Earth, including backend-configurable live sources, a draggable/resizable live-news HUD window, default global news channels, and a dedicated settings workflow so the Earth page can open real news playback instead of only static telemetry. + +### Added + +- Added [backend/app/api/v1/tv.py](/home/ray/dev/linkong/planet/backend/app/api/v1/tv.py), [backend/app/services/tv_streams.py](/home/ray/dev/linkong/planet/backend/app/services/tv_streams.py), and [backend/app/services/collectors/news_live_streams.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/news_live_streams.py) to provide TV source configuration, public stream payloads, a guarded HLS proxy path, and a collector entry point for future world-news live-source ingestion. +- Added the Earth TV HUD workspace through [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js), and [frontend/public/earth/css/tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css), including toolbar access, draggable/closable behavior, resize support, direct video/HLS playback, iframe fallback, and per-channel external-open handling. +- Added [docs/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/earth-tv-live-module-plan.md) and [docs/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion. + +### Improved + +- Improved [frontend/src/pages/Settings/Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx), [backend/app/api/v1/settings.py](/home/ray/dev/linkong/planet/backend/app/api/v1/settings.py), and [backend/app/core/datasource_defaults.py](/home/ray/dev/linkong/planet/backend/app/core/datasource_defaults.py) by adding TV source administration to system settings and registering the `news_live_streams` datasource as a first-class configurable collector. +- Improved [backend/app/services/tv_streams.py](/home/ray/dev/linkong/planet/backend/app/services/tv_streams.py) by seeding a curated first-pass news channel catalog that now defaults to `CGTN English` YouTube playback while keeping `CCTV-4` as a built-in fallback and exposing additional Reuters, CGTN, DW, Al Jazeera, Arirang, ABP, and SABC entries for operator testing. + +### Fixed + +- Fixed [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js) and [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html) so HLS/video playback now actively attempts autoplay in the TV panel instead of only loading metadata and leaving the player visually idle. +- Fixed [frontend/public/earth/css/tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css) so the TV source selector and action controls better match the Earth HUD dark theme instead of falling back to a bright native dropdown presentation. + ## 0.25.2 Released: 2026-04-10 diff --git a/docs/earth-tv-live-module-plan.md b/docs/earth-tv-live-module-plan.md new file mode 100644 index 00000000..aac933a3 --- /dev/null +++ b/docs/earth-tv-live-module-plan.md @@ -0,0 +1,117 @@ +# Earth 电视直播模块计划 + +## 目标 + +为 `Earth` 页面增加一个可配置、可扩展、可拖拽的电视直播模块: + +- 后台可配置新闻直播源 +- 默认兜底源为央视 `CCTV-4` +- 未来可通过采集器接入世界各地新闻直播源 +- Earth 工具栏 `显示控制` 子菜单新增电视按钮 +- 点击后打开一个与其他 HUD 一致的可拖拽/可关闭窗口 +- 窗口内部可播放或承载新闻直播页面 + +## 设计原则 + +- 第一阶段先交付“后台可配 + Earth 可用 + 默认可回退”的版本 +- 公开读取接口与后台管理接口分离 +- 手工配置源与采集器源共用统一的前端消费结构 +- Earth 里的电视窗口必须复用现有 HUD 拖拽、关闭、布局最大化逻辑 +- 小屏下优先保证窗口完整显示,超出部分在窗口内部滚动 + +## 分阶段实现 + +### Phase 1:后端配置与公开读取 + +- 在系统设置中新增 `tv` 分类 +- 定义直播源配置结构: + - `default_source_id` + - `auto_fallback` + - `sources[]` +- 每个直播源至少包含: + - `id` + - `name` + - `provider` + - `region` + - `language` + - `source_type` + - `embed_url` + - `stream_url` + - `homepage_url` + - `is_enabled` + - `is_fallback` + - `sort_order` + - `collector_source` + - `notes` +- 默认兜底源使用央视官网 `CCTV-4` 直播页 +- 新增公开读取接口,供 Earth 页面无登录态读取直播源配置 + +### Phase 2:采集器扩展位 + +- 新增 `news_live_streams` collector 占位 +- 规范采集器入库数据结构,使其能与后台手工配置源合并 +- TV 公开接口支持合并: + - 后台手工配置源 + - 采集器入库源 +- 保持手工配置源优先级更高,避免采集器覆盖人工兜底配置 + +### Phase 3:后台配置界面 + +- 在系统配置页新增 `电视直播` tab +- 支持: + - 查看当前默认源 + - 开关自动回退 + - 新增直播源 + - 编辑直播源 + - 删除直播源 + - 启用/禁用直播源 + - 将某个直播源设为默认源 +- 明确区分: + - 手工配置源 + - 采集器来源 + +### Phase 4:Earth HUD 集成 + +- 在 `显示控制` 子菜单加入电视按钮 +- 新增 TV HUD 面板: + - 可拖拽 + - 可关闭 + - 支持显示/隐藏状态同步 + - 参与布局最大化与恢复布局 +- 面板内容至少包含: + - 当前频道标题 + - 源切换下拉菜单 + - 刷新按钮 + - 打开官网按钮 + - 播放区域 + +### Phase 5:播放策略 + +- 第一版优先支持 `iframe`/嵌入页类直播源 +- 为未来扩展保留: + - `hls` + - `video` + - `external` +- 如果默认源不可用: + - 优先回退到标记为 `is_fallback=true` 的源 + - 若无明确回退源,则回退到第一个可用源 +- 面板内要有清晰的加载、错误、回退提示 + +### Phase 6:打磨与清理 + +- 统一 HUD 风格 +- 小屏下限制窗口尺寸并启用内部滚动 +- 避免窗口超出屏幕 +- 补最小验证 +- 清理临时代码、重复样式和无用资源 + +## 首版交付定义 + +当以下条件满足时,认为首版可用: + +- 后台可以配置新闻直播源 +- Earth 可以读取并显示默认直播源 +- 工具栏可打开电视窗口 +- 电视窗口可拖拽、可关闭 +- 央视 `CCTV-4` 作为默认兜底源可被使用 +- 代码结构已为后续采集器接入预留统一接口 diff --git a/docs/news-live-streams-collector-format.md b/docs/news-live-streams-collector-format.md new file mode 100644 index 00000000..5233b97e --- /dev/null +++ b/docs/news-live-streams-collector-format.md @@ -0,0 +1,97 @@ +# News Live Streams Collector Format + +`news_live_streams` 采集器面向“频道目录 JSON”输入,而不是直接抓网页。 + +这样做的目标是: + +- 让后台能够稳定接入世界各地新闻直播源 +- 让 `Earth` 页面电视模块始终消费统一结构 +- 便于后续接入类似 `worldmonitor` 那种 YouTube / HLS / iframe 混合频道目录 + +## 推荐 JSON 结构 + +```json +{ + "sources": [ + { + "id": "bbc-world-news", + "name": "BBC World News", + "provider": "BBC", + "region": "UK", + "language": "en", + "source_type": "youtube", + "youtube_video_id": "dQw4w9WgXcQ", + "youtube_channel": "https://www.youtube.com/@BBCNews", + "embed_url": "", + "stream_url": "", + "homepage_url": "https://www.youtube.com/@BBCNews/live", + "poster_url": "", + "sort_order": 220, + "is_enabled": true, + "notes": "Primary English global news channel" + }, + { + "id": "france24-en", + "name": "France 24 English", + "provider": "France 24", + "region": "France", + "language": "en", + "source_type": "hls", + "stream_url": "https://example.com/live.m3u8", + "homepage_url": "https://www.france24.com/en/live", + "sort_order": 230, + "is_enabled": true + }, + { + "id": "cctv4-page", + "name": "CCTV-4 中文国际", + "provider": "CCTV", + "region": "China", + "language": "zh-CN", + "source_type": "iframe", + "embed_url": "https://tv.cctv.com/live/cctv4/", + "homepage_url": "https://tv.cctv.com/live/cctv4/", + "sort_order": 10, + "is_enabled": true + } + ] +} +``` + +## 字段约定 + +- `id`: 唯一标识,建议稳定不变 +- `name`: 频道显示名 +- `provider`: 提供方 +- `region`: 国家或地区 +- `language`: 语言代码 +- `source_type`: `iframe` / `hls` / `video` / `external` / `youtube` +- `embed_url`: 适合 iframe 内嵌的页面 +- `stream_url`: 直接视频流地址 +- `homepage_url`: 官网或频道页 +- `youtube_video_id`: YouTube 直播视频 ID +- `youtube_channel`: YouTube 频道 handle 或频道 URL +- `poster_url`: 封面图,可选 +- `sort_order`: 排序值,越小越靠前 +- `is_enabled`: 是否启用 +- `notes`: 简短备注 + +## 面板行为约定 + +- `youtube` + - 优先使用 `youtube_video_id` + - 无法内嵌时至少保留 `youtube_channel` 或 `homepage_url` 供外部打开 +- `hls` / `video` + - 优先走 `stream_url` +- `iframe` + - 优先走 `embed_url` +- `external` + - 不尝试内嵌,只保留外部打开 + +## 当前实现状态 + +- 后台设置页可以手工维护频道目录 +- `Earth` 电视模块会合并: + - 手工配置源 + - `news_live_streams` 采集器采集源 +- 当前默认兜底源为 `CCTV-4 中文国际` diff --git a/docs/version-history.md b/docs/version-history.md index 672df950..5bb3e5bb 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.25.3` +- `dev` 当前开发分支历史推导到:`0.26.0` ## Timeline @@ -85,6 +85,7 @@ | `0.25.1` | bugfix | `dev` | `pending` | clean duplicated Playground flow code, add reusable code-hygiene rules, and fix first-level sidebar menu expansion behavior across route navigation and refresh | | `0.25.2` | bugfix | `dev` | `pending` | refine Earth settings modal sizing, eliminate first-frame HUD scale flicker, and make dragged HUD panels animate cleanly through maximized layout transitions | | `0.25.3` | bugfix | `dev` | `pending` | refactor the Earth HUD visual system, extract the top-left Earth brand into a reusable language-driven component, and consolidate duplicated brand assets into a single canonical set | +| `0.26.0` | feature | `dev` | `pending` | add the Earth TV live module with backend-configurable sources, a draggable/resizable TV HUD window, a first curated news channel catalog, and TV source management hooks in system settings | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/package.json b/frontend/package.json index 2946501f..bc8aab64 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.25.3", + "version": "0.26.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/css/tv-panel.css b/frontend/public/earth/css/tv-panel.css new file mode 100644 index 00000000..b85b7b0d --- /dev/null +++ b/frontend/public/earth/css/tv-panel.css @@ -0,0 +1,222 @@ +/* tv-panel */ + +.hud-panel-tv { + top: calc(96px * var(--hud-scale)); + right: calc(92px * var(--hud-scale)); + width: min(calc(460px * var(--hud-scale)), calc(100vw - 32px)); + min-width: calc(360px * var(--hud-scale)); + min-height: calc(340px * var(--hud-scale)); + padding: calc(18px * var(--hud-scale)); + display: flex; + flex-direction: column; + gap: var(--hud-gap-sm); + z-index: 18; +} + +.tv-panel-header-copy { + display: grid; + gap: calc(3px * var(--hud-scale)); +} + +.tv-panel-status { + color: var(--hud-text-soft); + font-size: calc(0.68rem * var(--hud-scale)); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.tv-panel-controls { + display: flex; + gap: var(--hud-gap-sm); + align-items: center; + min-width: 0; +} + +.tv-panel-select { + flex: 1 1 auto; + min-width: 0; + appearance: none; + -webkit-appearance: none; + color-scheme: dark; + border: 1px solid rgba(201, 225, 247, 0.14); + border-radius: calc(12px * var(--hud-scale)); + background: rgba(255, 255, 255, 0.04); + color: var(--hud-text); + padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale)); + font-size: calc(0.84rem * var(--hud-scale)); +} + +.tv-panel-select option, +.tv-panel-select optgroup { + background: #0a1422; + color: #eef5fc; +} + +.tv-panel-actions { + display: flex; + gap: var(--hud-gap-xs); +} + +.tv-panel-action { + border: 1px solid rgba(201, 225, 247, 0.12); + border-radius: calc(12px * var(--hud-scale)); + background: rgba(255, 255, 255, 0.05); + color: var(--hud-text); + padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale)); + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + font-size: calc(0.84rem * var(--hud-scale)); + line-height: 1; + cursor: pointer; + transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; +} + +.tv-panel-action:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(225, 239, 255, 0.2); + color: var(--hud-accent-strong); +} + +.tv-panel-action:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.tv-panel-meta { + display: grid; + gap: calc(4px * var(--hud-scale)); +} + +.tv-panel-title { + color: var(--hud-text); + font-size: calc(0.96rem * var(--hud-scale)); + font-weight: 600; +} + +.tv-panel-subtitle { + color: var(--hud-text-muted); + font-size: calc(0.74rem * var(--hud-scale)); + line-height: 1.35; +} + +.tv-panel-notes { + color: var(--hud-text-soft); + font-size: calc(0.68rem * var(--hud-scale)); + line-height: 1.45; +} + +.tv-panel-catalog { + color: var(--hud-text-soft); + font-size: calc(0.66rem * var(--hud-scale)); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.tv-panel-player { + position: relative; + flex: 1 1 auto; + min-height: calc(220px * var(--hud-scale)); + border-radius: calc(16px * var(--hud-scale)); + overflow: hidden; + border: 1px solid rgba(201, 225, 247, 0.1); + background: + linear-gradient(180deg, rgba(9, 18, 32, 0.94), rgba(5, 11, 22, 0.94)); +} + +.tv-panel-empty, +.tv-panel-iframe, +.tv-panel-video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.tv-panel-empty { + display: grid; + place-items: center; + padding: calc(18px * var(--hud-scale)); + text-align: center; + color: var(--hud-text-muted); + font-size: calc(0.84rem * var(--hud-scale)); + line-height: 1.5; +} + +.tv-panel-iframe, +.tv-panel-video { + border: 0; + background: #050a14; +} + +.tv-panel-resize-handle { + position: absolute; + right: calc(8px * var(--hud-scale)); + bottom: calc(8px * var(--hud-scale)); + width: calc(18px * var(--hud-scale)); + height: calc(18px * var(--hud-scale)); + border: 0; + padding: 0; + background: transparent; + cursor: nwse-resize; + z-index: 2; +} + +.tv-panel-resize-handle::before { + content: ""; + position: absolute; + inset: 0; + border-right: 2px solid rgba(223, 235, 248, 0.46); + border-bottom: 2px solid rgba(223, 235, 248, 0.46); + border-bottom-right-radius: calc(10px * var(--hud-scale)); + opacity: 0.78; + transition: opacity 0.18s ease, border-color 0.18s ease; +} + +.tv-panel-resize-handle:hover::before { + opacity: 1; + border-color: rgba(244, 249, 255, 0.78); +} + +.hud-panel-tv.is-resizing { + transition: none !important; + user-select: none; +} + +.earth-app.layout-expanded .hud-panel-tv { + top: calc(50% + 12px); + left: 50%; + right: auto; + width: min(calc(760px * var(--hud-scale)), calc(100vw - 48px)); + min-height: calc(410px * var(--hud-scale)); + transform: translateX(-50%); +} + +@media (max-width: 900px) { + .hud-panel-tv { + left: 16px; + right: 16px; + top: auto; + bottom: 16px; + width: auto; + min-width: 0; + min-height: min(48vh, 360px); + } + + .tv-panel-controls { + flex-wrap: wrap; + } + + .tv-panel-actions { + width: 100%; + } + + .tv-panel-action { + flex: 1 1 0; + } + + .tv-panel-resize-handle { + display: none; + } +} diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index b176841c..8cd83cb8 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -9,7 +9,8 @@ "imports": { "three": "https://esm.sh/three@0.128.0", "simplex-noise": "https://esm.sh/simplex-noise@4.0.1", - "satellite.js": "https://esm.sh/satellite.js@5.0.0" + "satellite.js": "https://esm.sh/satellite.js@5.0.0", + "hls.js": "https://esm.sh/hls.js@1.6.15" } } @@ -34,6 +35,7 @@ + @@ -118,6 +120,12 @@ 隐藏线缆 + + +
+ +
+ + +
+
+
+
暂无可用频道
+
当前未配置可播放新闻直播源
+
频道目录待同步
+
支持后台配置默认源与采集器补充源。
+
+
+
暂无可播放直播源,请先在系统配置中添加频道。
+ + +
+ + +
正在初始化全球态势数据...
@@ -312,6 +364,16 @@ +
diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index 8a9114fc..a883e6a2 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -17,6 +17,7 @@ import { } from "./satellites.js"; import { getShowCables } from "./cables.js"; import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js"; +import { ensureTVPanelReady } from "./tv.js"; export let autoRotate = true; export let zoomLevel = 1.0; @@ -30,6 +31,7 @@ const HUD_PANEL_IDS = [ "legend", "coordinates-display", "earth-stats", + "tv-panel", ]; const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable"; const PANEL_LAYOUT_ANIMATION_MS = 420; @@ -88,6 +90,14 @@ function setHudPanelVisibility(panelId, visible) { if (!panel) return; panel.classList.toggle("hud-panel-hidden", !visible); syncSettingsToggle(panelId, visible); + if (panelId === "tv-panel") { + updateTVToggleUI(visible); + if (visible) { + ensureTVPanelReady().catch((error) => { + console.error("初始化电视直播面板失败:", error); + }); + } + } } function syncSettingsToggle(panelId, visible) { @@ -273,6 +283,13 @@ function bindFloatingMenu(trigger, group) { }); } +function updateTVToggleUI(visible) { + const btn = document.getElementById("toggle-tv"); + if (!btn) return; + btn.classList.toggle("active", visible); + setButtonTooltip(btn, visible ? "关闭新闻直播" : "打开新闻直播"); +} + function bindListener(element, eventName, handler, options) { if (!element) return; element.addEventListener(eventName, handler, options); @@ -656,6 +673,9 @@ function setupTerrainControls() { showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info"); }); + updateTVToggleUI( + !document.getElementById("tv-panel")?.classList.contains("hud-panel-hidden"), + ); updateLayoutUI(container); } diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index 419e5d44..b4309bb0 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -124,6 +124,7 @@ import { setLegendItems, } from "./legend.js"; import { mountBrand } from "./brand.js"; +import { initTVPanel } from "./tv.js"; export let scene; export let camera; @@ -187,6 +188,8 @@ const HUD_INTERACTIVE_SELECTORS = [ "#legend *", "#earth-stats", "#earth-stats *", + "#tv-panel", + "#tv-panel *", ]; function bindListener(target, eventName, handler, options) { @@ -856,6 +859,7 @@ export function init() { updateHudScale(); const brandRoot = document.getElementById("brand-root"); mountBrand(brandRoot, HUD_CONFIG.brandLanguage); + initTVPanel(); scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera( diff --git a/frontend/public/earth/js/tv.js b/frontend/public/earth/js/tv.js new file mode 100644 index 00000000..d0f1fcfb --- /dev/null +++ b/frontend/public/earth/js/tv.js @@ -0,0 +1,596 @@ +import Hls from "hls.js"; +import { showStatusMessage } from "./ui.js"; + +const TV_STREAMS_API = "/api/v1/tv/streams"; +const TV_PROXY_API = "/api/v1/tv/proxy"; +const TV_STATUS_MESSAGE = { + idle: "等待加载直播源", + syncing: "正在同步直播源...", + empty: "暂无可播放直播源", + iframeReady: "直播页已加载", + videoReady: "视频流已加载", + videoError: "当前视频流不可播放,请尝试其他频道", + externalOnly: "当前频道仅支持外部打开", + loadFailed: "电视直播源加载失败", +}; + +let tvPayload = null; +let currentSourceId = ""; +let initialized = false; +let refreshPromise = null; +let hlsPlayer = null; +let hlsRecoveryAttempts = 0; + +const HLS_MAX_RECOVERY_ATTEMPTS = 3; +const HLS_RETRY_CONFIG = { + maxNumRetry: 4, + retryDelayMs: 1500, + maxRetryDelayMs: 8000, + backoff: "exponential", +}; + +function getElements() { + return { + panel: document.getElementById("tv-panel"), + toggleBtn: document.getElementById("toggle-tv"), + resizeHandle: document.getElementById("tv-resize-handle"), + select: document.getElementById("tv-source-select"), + title: document.getElementById("tv-source-title"), + meta: document.getElementById("tv-source-meta"), + catalog: document.getElementById("tv-source-catalog"), + status: document.getElementById("tv-source-status"), + notes: document.getElementById("tv-source-notes"), + iframe: document.getElementById("tv-iframe"), + video: document.getElementById("tv-video"), + empty: document.getElementById("tv-empty-state"), + refreshBtn: document.getElementById("tv-refresh"), + openBtn: document.getElementById("tv-open-external"), + }; +} + +function clearPanelPositioningForResize(panel) { + panel.style.left = `${panel.offsetLeft}px`; + panel.style.top = `${panel.offsetTop}px`; + panel.style.right = "auto"; + panel.style.bottom = "auto"; + panel.style.transform = "none"; + panel.dataset.dragged = "true"; +} + +function getHudScale() { + const scale = Number.parseFloat( + getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"), + ); + return Number.isFinite(scale) && scale > 0 ? scale : 1; +} + +function setupResizeHandle() { + const { panel, resizeHandle } = getElements(); + const container = document.getElementById("container"); + if (!(panel instanceof HTMLElement) || !(resizeHandle instanceof HTMLElement) || !(container instanceof HTMLElement)) { + return; + } + + let resizing = false; + let startX = 0; + let startY = 0; + let startWidth = 0; + let startHeight = 0; + + const stopResize = () => { + resizing = false; + panel.classList.remove("is-resizing"); + document.body.style.userSelect = ""; + }; + + resizeHandle.addEventListener("pointerdown", (event) => { + if (document.getElementById("container")?.classList.contains("layout-expanded")) { + return; + } + event.preventDefault(); + event.stopPropagation(); + resizing = true; + startX = event.clientX; + startY = event.clientY; + + clearPanelPositioningForResize(panel); + + const rect = panel.getBoundingClientRect(); + startWidth = rect.width; + startHeight = rect.height; + panel.classList.add("is-resizing"); + document.body.style.userSelect = "none"; + resizeHandle.setPointerCapture?.(event.pointerId); + }); + + resizeHandle.addEventListener("pointermove", (event) => { + if (!resizing) return; + const containerRect = container.getBoundingClientRect(); + const panelRect = panel.getBoundingClientRect(); + const currentLeft = panelRect.left - containerRect.left; + const currentTop = panelRect.top - containerRect.top; + const hudScale = getHudScale(); + const minWidth = Math.max(320, Math.round(360 * hudScale)); + const minHeight = Math.max(260, Math.round(340 * hudScale)); + const maxWidth = Math.max(minWidth, containerRect.width - currentLeft - 12); + const maxHeight = Math.max(minHeight, containerRect.height - currentTop - 12); + const nextWidth = Math.min( + maxWidth, + Math.max(minWidth, startWidth + (event.clientX - startX)), + ); + const nextHeight = Math.min( + maxHeight, + Math.max(minHeight, startHeight + (event.clientY - startY)), + ); + + panel.style.width = `${nextWidth}px`; + panel.style.minHeight = `${nextHeight}px`; + }); + + resizeHandle.addEventListener("pointerup", stopResize); + resizeHandle.addEventListener("pointercancel", stopResize); + resizeHandle.addEventListener("lostpointercapture", stopResize); +} + +function updateToggleButton(visible) { + const { toggleBtn } = getElements(); + if (!toggleBtn) return; + toggleBtn.classList.toggle("active", visible); + const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip"); + if (tooltip) { + tooltip.textContent = visible ? "关闭新闻直播" : "打开新闻直播"; + } +} + +function syncSettingsToggle(visible) { + const input = document.querySelector('[data-settings-panel="tv-panel"]'); + if (input instanceof HTMLInputElement) { + input.checked = visible; + } +} + +function setPanelVisible(visible) { + const { panel } = getElements(); + if (!panel) return; + panel.classList.toggle("hud-panel-hidden", !visible); + updateToggleButton(visible); + syncSettingsToggle(visible); +} + +function getEmbeddedUrl(source) { + if (!source) return ""; + if (source.source_type === "youtube" && source.youtube_video_id) { + const videoId = encodeURIComponent(source.youtube_video_id); + return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&playsinline=1&rel=0`; + } + if (source.source_type === "external") return ""; + if (source.source_type === "video" || source.source_type === "hls") { + return ""; + } + return source.embed_url || source.homepage_url || ""; +} + +function buildProxyUrl(url) { + if (!url) return ""; + return `${TV_PROXY_API}?url=${encodeURIComponent(url)}`; +} + +function getVideoUrl(source) { + if (!source) return ""; + if (source.source_type !== "video" && source.source_type !== "hls") { + return ""; + } + return buildProxyUrl(source.stream_url || source.embed_url || ""); +} + +function destroyHlsPlayer() { + if (hlsPlayer) { + hlsPlayer.destroy(); + hlsPlayer = null; + } + hlsRecoveryAttempts = 0; +} + +function showEmbeddedFallback(source, reasonMessage = TV_STATUS_MESSAGE.videoError) { + const { iframe, video, empty } = getElements(); + const embeddedUrl = getEmbeddedUrl(source); + if (!embeddedUrl) { + setPanelMessage(reasonMessage); + return false; + } + + destroyHlsPlayer(); + + if (video) { + video.removeAttribute("src"); + video.hidden = true; + video.load(); + } + + if (iframe) { + iframe.hidden = false; + if (iframe.src !== embeddedUrl) { + iframe.src = embeddedUrl; + } + } + + if (empty) { + empty.hidden = true; + } + + setPanelMessage("直播放流不可用,已回退到官网直播页"); + return true; +} + +function canPlayNativeHls(video, sourceUrl) { + if (!(video instanceof HTMLVideoElement) || !sourceUrl) return false; + const isLikelyHls = sourceUrl.includes(".m3u8") || sourceUrl.includes("mpegurl"); + if (!isLikelyHls) return false; + return video.canPlayType("application/vnd.apple.mpegurl") !== ""; +} + +function tryStartPlayback(video) { + if (!(video instanceof HTMLVideoElement)) return; + video.autoplay = true; + video.muted = true; + const playPromise = video.play(); + if (playPromise && typeof playPromise.catch === "function") { + playPromise.catch((error) => { + console.warn("TV 自动播放未成功:", error); + setPanelMessage("已加载视频流,点击播放继续"); + }); + } +} + +function attachVideoSource(video, source) { + const sourceUrl = getVideoUrl(source); + if (!(video instanceof HTMLVideoElement) || !sourceUrl) return; + + destroyHlsPlayer(); + video.autoplay = true; + video.muted = true; + + if (source.source_type === "hls") { + if (canPlayNativeHls(video, sourceUrl)) { + video.src = sourceUrl; + video.load(); + tryStartPlayback(video); + return; + } + + if (Hls.isSupported()) { + hlsPlayer = new Hls({ + enableWorker: true, + lowLatencyMode: false, + manifestLoadingTimeOut: 20000, + levelLoadingTimeOut: 20000, + fragLoadingTimeOut: 25000, + fragLoadingMaxRetry: 3, + fragLoadingRetryDelay: 1500, + levelLoadingMaxRetry: 3, + levelLoadingRetryDelay: 1500, + manifestLoadingMaxRetry: 2, + manifestLoadingRetryDelay: 1500, + liveSyncDurationCount: 4, + liveMaxLatencyDurationCount: 10, + manifestLoadPolicy: { + default: { + maxTimeToFirstByteMs: 12000, + maxLoadTimeMs: 20000, + timeoutRetry: { + ...HLS_RETRY_CONFIG, + maxNumRetry: 2, + }, + errorRetry: { + ...HLS_RETRY_CONFIG, + maxNumRetry: 2, + }, + }, + }, + playlistLoadPolicy: { + default: { + maxTimeToFirstByteMs: 12000, + maxLoadTimeMs: 20000, + timeoutRetry: HLS_RETRY_CONFIG, + errorRetry: HLS_RETRY_CONFIG, + }, + }, + fragLoadPolicy: { + default: { + maxTimeToFirstByteMs: 12000, + maxLoadTimeMs: 30000, + timeoutRetry: HLS_RETRY_CONFIG, + errorRetry: HLS_RETRY_CONFIG, + }, + }, + }); + hlsPlayer.loadSource(sourceUrl); + hlsPlayer.attachMedia(video); + hlsPlayer.on(Hls.Events.MANIFEST_PARSED, () => { + hlsRecoveryAttempts = 0; + setPanelMessage(TV_STATUS_MESSAGE.videoReady); + tryStartPlayback(video); + }); + hlsPlayer.on(Hls.Events.ERROR, (_event, data) => { + console.error("HLS 播放失败:", data); + if (!data?.fatal) { + if (data?.type === Hls.ErrorTypes.NETWORK_ERROR) { + setPanelMessage("直播流网络波动,正在重试..."); + return; + } + if (data?.type === Hls.ErrorTypes.MEDIA_ERROR) { + setPanelMessage("直播流正在恢复..."); + return; + } + } + + if (data?.fatal && hlsRecoveryAttempts < HLS_MAX_RECOVERY_ATTEMPTS) { + hlsRecoveryAttempts += 1; + if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { + setPanelMessage(`直播流连接异常,正在重试 (${hlsRecoveryAttempts}/${HLS_MAX_RECOVERY_ATTEMPTS})...`); + hlsPlayer?.startLoad(); + return; + } + if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { + setPanelMessage(`直播流解码异常,正在恢复 (${hlsRecoveryAttempts}/${HLS_MAX_RECOVERY_ATTEMPTS})...`); + hlsPlayer?.recoverMediaError(); + return; + } + } + + if (!showEmbeddedFallback(source)) { + setPanelMessage(TV_STATUS_MESSAGE.videoError); + } + }); + return; + } + } + + video.src = sourceUrl; + video.load(); + tryStartPlayback(video); +} + +function getExternalUrl(source) { + return source?.homepage_url || source?.youtube_channel || source?.embed_url || source?.stream_url || ""; +} + +function updateOpenButton(source) { + const { openBtn } = getElements(); + if (!openBtn) return; + const targetUrl = getExternalUrl(source); + openBtn.disabled = !targetUrl; + openBtn.onclick = targetUrl + ? () => { + window.open(targetUrl, "_blank", "noopener,noreferrer"); + } + : null; +} + +function findSourceById(sourceId) { + return tvPayload?.sources?.find((source) => source.id === sourceId) || null; +} + +function setPanelMessage(message) { + const { status } = getElements(); + if (status) { + status.textContent = message || TV_STATUS_MESSAGE.idle; + } +} + +function renderSourceOptions() { + const { select } = getElements(); + if (!select) return; + const sources = tvPayload?.sources || []; + const fragment = document.createDocumentFragment(); + + sources.forEach((source) => { + const marker = source.id === tvPayload?.default_source_id ? " · 默认" : ""; + const option = document.createElement("option"); + option.value = source.id; + option.textContent = `${source.name}${marker}`; + fragment.appendChild(option); + }); + + select.replaceChildren(fragment); + if (currentSourceId) { + select.value = currentSourceId; + } +} + +function renderSource(source) { + const { title, meta, catalog, notes, iframe, video, empty } = getElements(); + const embeddedUrl = getEmbeddedUrl(source); + const videoUrl = getVideoUrl(source); + const externalUrl = getExternalUrl(source); + const isVideo = Boolean(videoUrl); + const isExternalOnly = Boolean(source) && !embeddedUrl && !videoUrl && Boolean(externalUrl); + + if (title) { + title.textContent = source?.name || "暂无可用频道"; + } + if (meta) { + meta.textContent = source + ? `${source.provider} · ${source.region} · ${source.language} · ${source.source_type}` + : "当前未配置可播放新闻直播源"; + } + if (catalog) { + const sourceCount = tvPayload?.source_count || tvPayload?.sources?.length || 0; + const latestUpdatedAt = tvPayload?.latest_updated_at || tvPayload?.generated_at || ""; + const latestLabel = latestUpdatedAt + ? `最近同步 ${new Date(latestUpdatedAt).toLocaleString("zh-CN", { hour12: false })}` + : "尚未同步"; + const collectorLabel = source?.collector_source ? ` · 采集器 ${source.collector_source}` : ""; + catalog.textContent = `共 ${sourceCount} 个频道 · ${latestLabel}${collectorLabel}`; + } + if (notes) { + notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。"; + } + + if (!source || (!embeddedUrl && !videoUrl)) { + destroyHlsPlayer(); + if (iframe) { + iframe.removeAttribute("src"); + iframe.hidden = true; + } + if (video) { + video.removeAttribute("src"); + video.hidden = true; + video.load(); + } + if (empty) { + empty.hidden = false; + empty.textContent = isExternalOnly + ? "当前频道仅支持跳转官网或外部播放器打开。" + : "暂无可播放直播源,请先在系统配置中添加频道。"; + } + setPanelMessage(isExternalOnly ? TV_STATUS_MESSAGE.externalOnly : TV_STATUS_MESSAGE.empty); + updateOpenButton(source); + return; + } + + if (isVideo && videoUrl) { + if (iframe) { + iframe.removeAttribute("src"); + iframe.hidden = true; + } + if (video) { + video.hidden = false; + attachVideoSource(video, source); + } + } else { + destroyHlsPlayer(); + if (video) { + video.removeAttribute("src"); + video.hidden = true; + video.load(); + } + if (iframe) { + iframe.hidden = false; + if (iframe.src !== embeddedUrl) { + iframe.src = embeddedUrl; + } + } + } + + if (empty) { + empty.hidden = true; + } + + setPanelMessage( + source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道", + ); + updateOpenButton(source); +} + +function resolveInitialSourceId() { + if (findSourceById(currentSourceId)) { + return currentSourceId; + } + return tvPayload?.selected_source?.id || tvPayload?.default_source_id || tvPayload?.sources?.[0]?.id || ""; +} + +function renderPanel() { + renderSourceOptions(); + currentSourceId = resolveInitialSourceId(); + renderSource(findSourceById(currentSourceId)); +} + +export async function loadTVStreams() { + const response = await fetch(TV_STREAMS_API, { + headers: { + Accept: "application/json", + }, + }); + if (!response.ok) { + throw new Error(`Failed to load TV streams: ${response.status}`); + } + tvPayload = await response.json(); + return tvPayload; +} + +export async function refreshTVPanel() { + if (refreshPromise) { + return refreshPromise; + } + + setPanelMessage(TV_STATUS_MESSAGE.syncing); + refreshPromise = (async () => { + try { + await loadTVStreams(); + renderPanel(); + } catch (error) { + console.error("加载电视直播源失败:", error); + setPanelMessage(TV_STATUS_MESSAGE.loadFailed); + renderSource(findSourceById(currentSourceId)); + } finally { + refreshPromise = null; + } + })(); + + return refreshPromise; +} + +export async function ensureTVPanelReady() { + if (!initialized) { + initTVPanel(); + } + if (!tvPayload) { + await refreshTVPanel(); + return; + } + renderPanel(); +} + +export function initTVPanel() { + if (initialized) return; + initialized = true; + + const { select, refreshBtn, iframe, video, toggleBtn, panel } = getElements(); + + updateToggleButton(!panel?.classList.contains("hud-panel-hidden")); + syncSettingsToggle(!panel?.classList.contains("hud-panel-hidden")); + + toggleBtn?.addEventListener("click", async (event) => { + event.preventDefault(); + event.stopPropagation(); + const nextVisible = panel?.classList.contains("hud-panel-hidden") ?? true; + setPanelVisible(nextVisible); + if (nextVisible) { + await ensureTVPanelReady(); + showStatusMessage("新闻直播窗口已打开", "info"); + } else { + showStatusMessage("新闻直播窗口已关闭", "info"); + } + }); + + select?.addEventListener("change", (event) => { + const target = event.currentTarget; + if (!(target instanceof HTMLSelectElement)) return; + currentSourceId = target.value; + renderSource(findSourceById(currentSourceId)); + }); + + refreshBtn?.addEventListener("click", () => { + refreshTVPanel(); + }); + + iframe?.addEventListener("load", () => { + if (iframe.hidden) return; + setPanelMessage(TV_STATUS_MESSAGE.iframeReady); + }); + + video?.addEventListener("loadedmetadata", () => { + if (video.hidden) return; + setPanelMessage(TV_STATUS_MESSAGE.videoReady); + }); + + video?.addEventListener("error", () => { + const currentSource = findSourceById(currentSourceId); + if (!showEmbeddedFallback(currentSource)) { + setPanelMessage(TV_STATUS_MESSAGE.videoError); + } + }); + + setupResizeHandle(); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 6d8b0f8a..0e6a8f44 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -2315,6 +2315,32 @@ body { max-height: none !important; } +.settings-tv-toolbar { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.settings-tv-toolbar__controls { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: flex-end; +} + +.settings-tv-toolbar__actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.settings-tv-field { + display: grid; + gap: 8px; +} + diff --git a/frontend/src/pages/Settings/Settings.tsx b/frontend/src/pages/Settings/Settings.tsx index 670f261d..d684dff0 100644 --- a/frontend/src/pages/Settings/Settings.tsx +++ b/frontend/src/pages/Settings/Settings.tsx @@ -55,6 +55,32 @@ interface CollectorSettings { next_run_at: string | null } +interface TVStreamSource { + id: string + name: string + provider: string + region: string + language: string + source_type: 'iframe' | 'hls' | 'video' | 'external' | 'youtube' + embed_url: string + stream_url: string + homepage_url: string + poster_url: string + youtube_video_id: string + youtube_channel: string + is_enabled: boolean + is_fallback: boolean + sort_order: number + collector_source: string | null + notes: string +} + +interface TVSettings { + default_source_id: string + auto_fallback: boolean + sources: TVStreamSource[] +} + function SettingsPanel({ loading, children, @@ -78,6 +104,8 @@ function Settings() { const [systemSettings, setSystemSettings] = useState(null) const [notificationSettings, setNotificationSettings] = useState(null) const [securitySettings, setSecuritySettings] = useState(null) + const [tvSettings, setTvSettings] = useState(null) + const [savingTvSettings, setSavingTvSettings] = useState(false) const collectorTableRegionRef = useRef(null) const [collectorTableHeight, setCollectorTableHeight] = useState(360) const [systemForm] = Form.useForm() @@ -91,6 +119,7 @@ function Settings() { setSystemSettings(response.data.system) setNotificationSettings(response.data.notifications) setSecuritySettings(response.data.security) + setTvSettings(response.data.tv || null) setCollectors(response.data.collectors || []) } catch (error) { message.error('获取系统配置失败') @@ -175,6 +204,100 @@ function Settings() { } } + const updateTvSetting = (field: K, value: TVSettings[K]) => { + setTvSettings((prev) => (prev ? { ...prev, [field]: value } : prev)) + } + + const updateTvSourceField = ( + sourceId: string, + field: K, + value: TVStreamSource[K] + ) => { + setTvSettings((prev) => { + if (!prev) return prev + const nextSources = prev.sources.map((source) => { + if (field === 'is_fallback' && value === true) { + return { ...source, is_fallback: source.id === sourceId } + } + if (source.id === sourceId) { + return { ...source, [field]: value } + } + return source + }) + + const nextDefaultSourceId = + field === 'is_enabled' && value === false && prev.default_source_id === sourceId + ? nextSources.find((source) => source.id !== sourceId && source.is_enabled)?.id || '' + : prev.default_source_id + + return { + ...prev, + default_source_id: nextDefaultSourceId, + sources: nextSources, + } + }) + } + + const addTvSource = () => { + setTvSettings((prev) => { + if (!prev) return prev + const nextIndex = prev.sources.length + 1 + const newSource: TVStreamSource = { + id: `manual-tv-${Date.now()}`, + name: `新闻直播源 ${nextIndex}`, + provider: 'Manual', + region: 'Global', + language: 'und', + source_type: 'iframe', + embed_url: '', + stream_url: '', + homepage_url: '', + poster_url: '', + youtube_video_id: '', + youtube_channel: '', + is_enabled: true, + is_fallback: false, + sort_order: nextIndex * 10, + collector_source: null, + notes: '', + } + + return { + ...prev, + sources: [...prev.sources, newSource], + } + }) + } + + const removeTvSource = (sourceId: string) => { + setTvSettings((prev) => { + if (!prev) return prev + const nextSources = prev.sources.filter((source) => source.id !== sourceId) + const nextDefaultSourceId = + prev.default_source_id === sourceId ? nextSources[0]?.id || '' : prev.default_source_id + return { + ...prev, + default_source_id: nextDefaultSourceId, + sources: nextSources, + } + }) + } + + const saveTvSettings = async () => { + if (!tvSettings) return + try { + setSavingTvSettings(true) + await axios.put('/api/v1/settings/tv', tvSettings) + message.success('电视直播配置已保存') + await fetchSettings() + } catch (error) { + message.error('电视直播配置保存失败') + console.error(error) + } finally { + setSavingTvSettings(false) + } + } + const collectorColumns = [ { title: '数据源', @@ -274,6 +397,133 @@ function Settings() { }, ] + const tvSourceColumns = [ + { + title: '频道', + dataIndex: 'name', + key: 'name', + width: 220, + render: (_: string, record: TVStreamSource) => ( +
+ updateTvSourceField(record.id, 'name', event.target.value)} /> + updateTvSourceField(record.id, 'provider', event.target.value)} + /> +
+ ), + }, + { + title: '区域 / 语言', + key: 'locale', + width: 160, + render: (_: unknown, record: TVStreamSource) => ( +
+ updateTvSourceField(record.id, 'region', event.target.value)} + /> + updateTvSourceField(record.id, 'language', event.target.value)} + /> +
+ ), + }, + { + title: '类型', + dataIndex: 'source_type', + key: 'source_type', + width: 120, + render: (value: TVStreamSource['source_type'], record: TVStreamSource) => ( + updateTvSourceField(record.id, 'embed_url', event.target.value)} + /> + updateTvSourceField(record.id, 'stream_url', event.target.value)} + /> + updateTvSourceField(record.id, 'youtube_video_id', event.target.value)} + /> + updateTvSourceField(record.id, 'youtube_channel', event.target.value)} + /> + + ), + }, + { + title: '官网', + dataIndex: 'homepage_url', + key: 'homepage_url', + width: 220, + render: (value: string, record: TVStreamSource) => ( + updateTvSourceField(record.id, 'homepage_url', event.target.value)} /> + ), + }, + { + title: '状态', + key: 'status', + width: 110, + render: (_: unknown, record: TVStreamSource) => ( +
+ updateTvSourceField(record.id, 'is_enabled', checked)} /> + updateTvSourceField(record.id, 'is_fallback', checked)} /> +
+ ), + }, + { + title: '备注', + dataIndex: 'notes', + key: 'notes', + width: 220, + render: (value: string, record: TVStreamSource) => ( + updateTvSourceField(record.id, 'notes', event.target.value)} /> + ), + }, + { + title: '操作', + key: 'action', + width: 90, + fixed: 'right' as const, + render: (_: unknown, record: TVStreamSource) => ( + + ), + }, + ] + const tabItems = [ { key: 'system', @@ -356,6 +606,58 @@ function Settings() { ), }, + { + key: 'tv', + label: '电视直播', + children: ( +
+ +
+
+
+
+ 默认直播源 +