release: bump version to 0.26.0
This commit is contained in:
@@ -14,6 +14,7 @@ from app.api.v1 import (
|
|||||||
visualization,
|
visualization,
|
||||||
bgp,
|
bgp,
|
||||||
system_control,
|
system_control,
|
||||||
|
tv,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
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(system_control.router, prefix="/system", tags=["system"])
|
||||||
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
|
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
|
||||||
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
|
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
|
||||||
|
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from copy import deepcopy
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ from app.models.datasource import DataSource
|
|||||||
from app.models.system_setting import SystemSetting
|
from app.models.system_setting import SystemSetting
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.scheduler import sync_datasource_job
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ DEFAULT_SETTINGS = {
|
|||||||
"max_login_attempts": 5,
|
"max_login_attempts": 5,
|
||||||
"password_policy": "medium",
|
"password_policy": "medium",
|
||||||
},
|
},
|
||||||
|
"tv": DEFAULT_TV_SETTINGS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -67,8 +70,34 @@ class CollectorSettingsUpdate(BaseModel):
|
|||||||
frequency_minutes: int = Field(default=60, ge=1, le=10080)
|
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:
|
def merge_with_defaults(category: str, payload: Optional[dict]) -> dict:
|
||||||
merged = DEFAULT_SETTINGS[category].copy()
|
merged = deepcopy(DEFAULT_SETTINGS[category])
|
||||||
if payload:
|
if payload:
|
||||||
merged.update(payload)
|
merged.update(payload)
|
||||||
return merged
|
return merged
|
||||||
@@ -195,6 +224,25 @@ async def update_security_settings(
|
|||||||
return {"status": "updated", "security": payload}
|
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")
|
@router.get("/collectors")
|
||||||
async def get_collector_settings(
|
async def get_collector_settings(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
@@ -240,6 +288,7 @@ async def get_all_settings(
|
|||||||
"system": setting_payloads["system"],
|
"system": setting_payloads["system"],
|
||||||
"notifications": setting_payloads["notifications"],
|
"notifications": setting_payloads["notifications"],
|
||||||
"security": setting_payloads["security"],
|
"security": setting_payloads["security"],
|
||||||
|
"tv": await get_tv_settings_payload(db),
|
||||||
"collectors": [serialize_collector(datasource) for datasource in datasources],
|
"collectors": [serialize_collector(datasource) for datasource in datasources],
|
||||||
"generated_at": to_iso8601_utc(datetime.now(UTC)),
|
"generated_at": to_iso8601_utc(datetime.now(UTC)),
|
||||||
}
|
}
|
||||||
|
|||||||
70
backend/app/api/v1/tv.py
Normal file
70
backend/app/api/v1/tv.py
Normal file
@@ -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)
|
||||||
@@ -155,6 +155,13 @@ DEFAULT_DATASOURCES = {
|
|||||||
"priority": "P1",
|
"priority": "P1",
|
||||||
"frequency_minutes": 1440,
|
"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()}
|
ID_TO_COLLECTOR = {info["id"]: name for name, info in DEFAULT_DATASOURCES.items()}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from app.services.collectors.bgpstream import BGPStreamBackfillCollector
|
|||||||
from app.services.collectors.iptoasn import IPtoASNPrefixGeoCollector
|
from app.services.collectors.iptoasn import IPtoASNPrefixGeoCollector
|
||||||
from app.services.collectors.opengeofeed import OpenGeoFeedPrefixGeoCollector
|
from app.services.collectors.opengeofeed import OpenGeoFeedPrefixGeoCollector
|
||||||
from app.services.collectors.nro_delegated import NRODelegatedPrefixGeoCollector
|
from app.services.collectors.nro_delegated import NRODelegatedPrefixGeoCollector
|
||||||
|
from app.services.collectors.news_live_streams import NewsLiveStreamsCollector
|
||||||
|
|
||||||
collector_registry.register(TOP500Collector())
|
collector_registry.register(TOP500Collector())
|
||||||
collector_registry.register(EpochAIGPUCollector())
|
collector_registry.register(EpochAIGPUCollector())
|
||||||
@@ -61,3 +62,4 @@ collector_registry.register(BGPStreamBackfillCollector())
|
|||||||
collector_registry.register(IPtoASNPrefixGeoCollector())
|
collector_registry.register(IPtoASNPrefixGeoCollector())
|
||||||
collector_registry.register(OpenGeoFeedPrefixGeoCollector())
|
collector_registry.register(OpenGeoFeedPrefixGeoCollector())
|
||||||
collector_registry.register(NRODelegatedPrefixGeoCollector())
|
collector_registry.register(NRODelegatedPrefixGeoCollector())
|
||||||
|
collector_registry.register(NewsLiveStreamsCollector())
|
||||||
|
|||||||
79
backend/app/services/collectors/news_live_streams.py
Normal file
79
backend/app/services/collectors/news_live_streams.py
Normal file
@@ -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
|
||||||
466
backend/app/services/tv_streams.py
Normal file
466
backend/app/services/tv_streams.py
Normal file
@@ -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)
|
||||||
@@ -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.
|
- 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
|
## 0.25.2
|
||||||
|
|
||||||
Released: 2026-04-10
|
Released: 2026-04-10
|
||||||
|
|||||||
117
docs/earth-tv-live-module-plan.md
Normal file
117
docs/earth-tv-live-module-plan.md
Normal file
@@ -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` 作为默认兜底源可被使用
|
||||||
|
- 代码结构已为后续采集器接入预留统一接口
|
||||||
97
docs/news-live-streams-collector-format.md
Normal file
97
docs/news-live-streams-collector-format.md
Normal file
@@ -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 中文国际`
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
## Current Version
|
## Current Version
|
||||||
|
|
||||||
- `main` 当前主线历史推导到:`0.16.5`
|
- `main` 当前主线历史推导到:`0.16.5`
|
||||||
- `dev` 当前开发分支历史推导到:`0.25.3`
|
- `dev` 当前开发分支历史推导到:`0.26.0`
|
||||||
|
|
||||||
## Timeline
|
## 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.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.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.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
|
## Maintenance Commits Not Counted as Version Bumps
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.25.3",
|
"version": "0.26.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1",
|
"packageManager": "bun@1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
222
frontend/public/earth/css/tv-panel.css
Normal file
222
frontend/public/earth/css/tv-panel.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
"imports": {
|
"imports": {
|
||||||
"three": "https://esm.sh/three@0.128.0",
|
"three": "https://esm.sh/three@0.128.0",
|
||||||
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
<link rel="stylesheet" href="css/coordinates-display.css">
|
<link rel="stylesheet" href="css/coordinates-display.css">
|
||||||
<link rel="stylesheet" href="css/legend.css">
|
<link rel="stylesheet" href="css/legend.css">
|
||||||
<link rel="stylesheet" href="css/earth-stats.css">
|
<link rel="stylesheet" href="css/earth-stats.css">
|
||||||
|
<link rel="stylesheet" href="css/tv-panel.css">
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;500;600&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;500;600&display=swap">
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,500,0,0">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,500,0,0">
|
||||||
</head>
|
</head>
|
||||||
@@ -118,6 +120,12 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="tooltip earth-toolbar-tooltip">隐藏线缆</span>
|
<span class="tooltip earth-toolbar-tooltip">隐藏线缆</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
|
||||||
|
<span class="icon" aria-hidden="true">
|
||||||
|
<span class="material-symbols-rounded">live_tv</span>
|
||||||
|
</span>
|
||||||
|
<span class="tooltip earth-toolbar-tooltip">打开新闻直播</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
||||||
@@ -259,6 +267,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable hud-panel-hidden" data-panel-key="tv-panel">
|
||||||
|
<div class="hud-panel-header hud-panel-drag-handle">
|
||||||
|
<div class="tv-panel-header-copy">
|
||||||
|
<h3 class="hud-panel-title">新闻直播</h3>
|
||||||
|
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
|
||||||
|
</div>
|
||||||
|
<button class="hud-panel-close" type="button" data-close-panel="tv-panel" aria-label="关闭电视直播">
|
||||||
|
<span class="material-symbols-rounded">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="tv-panel-controls">
|
||||||
|
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
|
||||||
|
<div class="tv-panel-actions">
|
||||||
|
<button id="tv-refresh" class="tv-panel-action" type="button">刷新</button>
|
||||||
|
<button id="tv-open-external" class="tv-panel-action" type="button">官网</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tv-panel-meta">
|
||||||
|
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
|
||||||
|
<div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div>
|
||||||
|
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div>
|
||||||
|
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div>
|
||||||
|
</div>
|
||||||
|
<div class="tv-panel-player">
|
||||||
|
<div id="tv-empty-state" class="tv-panel-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||||
|
<iframe
|
||||||
|
id="tv-iframe"
|
||||||
|
class="tv-panel-iframe"
|
||||||
|
hidden
|
||||||
|
title="新闻直播"
|
||||||
|
referrerpolicy="strict-origin-when-cross-origin"
|
||||||
|
allow="autoplay; fullscreen; picture-in-picture"
|
||||||
|
></iframe>
|
||||||
|
<video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="tv-resize-handle"
|
||||||
|
class="tv-panel-resize-handle"
|
||||||
|
type="button"
|
||||||
|
aria-label="调整电视直播窗口大小"
|
||||||
|
title="调整大小"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="loading" class="earth-loading">
|
<div id="loading" class="earth-loading">
|
||||||
<div id="loading-spinner" class="earth-loading-spinner"></div>
|
<div id="loading-spinner" class="earth-loading-spinner"></div>
|
||||||
<div id="loading-title" class="earth-loading-title earth-loading-text">正在初始化全球态势数据...</div>
|
<div id="loading-title" class="earth-loading-title earth-loading-text">正在初始化全球态势数据...</div>
|
||||||
@@ -312,6 +364,16 @@
|
|||||||
<span class="earth-settings-switch-track"></span>
|
<span class="earth-settings-switch-track"></span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="earth-settings-item" for="toggle-view-tv">
|
||||||
|
<div class="earth-settings-copy">
|
||||||
|
<span class="earth-settings-item-title">电视直播</span>
|
||||||
|
<span class="earth-settings-item-subtitle">控制新闻直播窗口显示</span>
|
||||||
|
</div>
|
||||||
|
<span class="earth-settings-switch">
|
||||||
|
<input id="toggle-view-tv" type="checkbox" data-settings-panel="tv-panel">
|
||||||
|
<span class="earth-settings-switch-track"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
20
frontend/public/earth/js/controls.js
vendored
20
frontend/public/earth/js/controls.js
vendored
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "./satellites.js";
|
} from "./satellites.js";
|
||||||
import { getShowCables } from "./cables.js";
|
import { getShowCables } from "./cables.js";
|
||||||
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
|
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
|
||||||
|
import { ensureTVPanelReady } from "./tv.js";
|
||||||
|
|
||||||
export let autoRotate = true;
|
export let autoRotate = true;
|
||||||
export let zoomLevel = 1.0;
|
export let zoomLevel = 1.0;
|
||||||
@@ -30,6 +31,7 @@ const HUD_PANEL_IDS = [
|
|||||||
"legend",
|
"legend",
|
||||||
"coordinates-display",
|
"coordinates-display",
|
||||||
"earth-stats",
|
"earth-stats",
|
||||||
|
"tv-panel",
|
||||||
];
|
];
|
||||||
const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable";
|
const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable";
|
||||||
const PANEL_LAYOUT_ANIMATION_MS = 420;
|
const PANEL_LAYOUT_ANIMATION_MS = 420;
|
||||||
@@ -88,6 +90,14 @@ function setHudPanelVisibility(panelId, visible) {
|
|||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
panel.classList.toggle("hud-panel-hidden", !visible);
|
panel.classList.toggle("hud-panel-hidden", !visible);
|
||||||
syncSettingsToggle(panelId, visible);
|
syncSettingsToggle(panelId, visible);
|
||||||
|
if (panelId === "tv-panel") {
|
||||||
|
updateTVToggleUI(visible);
|
||||||
|
if (visible) {
|
||||||
|
ensureTVPanelReady().catch((error) => {
|
||||||
|
console.error("初始化电视直播面板失败:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncSettingsToggle(panelId, visible) {
|
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) {
|
function bindListener(element, eventName, handler, options) {
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
element.addEventListener(eventName, handler, options);
|
element.addEventListener(eventName, handler, options);
|
||||||
@@ -656,6 +673,9 @@ function setupTerrainControls() {
|
|||||||
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
|
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateTVToggleUI(
|
||||||
|
!document.getElementById("tv-panel")?.classList.contains("hud-panel-hidden"),
|
||||||
|
);
|
||||||
updateLayoutUI(container);
|
updateLayoutUI(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ import {
|
|||||||
setLegendItems,
|
setLegendItems,
|
||||||
} from "./legend.js";
|
} from "./legend.js";
|
||||||
import { mountBrand } from "./brand.js";
|
import { mountBrand } from "./brand.js";
|
||||||
|
import { initTVPanel } from "./tv.js";
|
||||||
|
|
||||||
export let scene;
|
export let scene;
|
||||||
export let camera;
|
export let camera;
|
||||||
@@ -187,6 +188,8 @@ const HUD_INTERACTIVE_SELECTORS = [
|
|||||||
"#legend *",
|
"#legend *",
|
||||||
"#earth-stats",
|
"#earth-stats",
|
||||||
"#earth-stats *",
|
"#earth-stats *",
|
||||||
|
"#tv-panel",
|
||||||
|
"#tv-panel *",
|
||||||
];
|
];
|
||||||
|
|
||||||
function bindListener(target, eventName, handler, options) {
|
function bindListener(target, eventName, handler, options) {
|
||||||
@@ -856,6 +859,7 @@ export function init() {
|
|||||||
updateHudScale();
|
updateHudScale();
|
||||||
const brandRoot = document.getElementById("brand-root");
|
const brandRoot = document.getElementById("brand-root");
|
||||||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||||||
|
initTVPanel();
|
||||||
|
|
||||||
scene = new THREE.Scene();
|
scene = new THREE.Scene();
|
||||||
camera = new THREE.PerspectiveCamera(
|
camera = new THREE.PerspectiveCamera(
|
||||||
|
|||||||
596
frontend/public/earth/js/tv.js
Normal file
596
frontend/public/earth/js/tv.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
@@ -2315,6 +2315,32 @@ body {
|
|||||||
max-height: none !important;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,32 @@ interface CollectorSettings {
|
|||||||
next_run_at: string | null
|
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({
|
function SettingsPanel({
|
||||||
loading,
|
loading,
|
||||||
children,
|
children,
|
||||||
@@ -78,6 +104,8 @@ function Settings() {
|
|||||||
const [systemSettings, setSystemSettings] = useState<SystemSettings | null>(null)
|
const [systemSettings, setSystemSettings] = useState<SystemSettings | null>(null)
|
||||||
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings | null>(null)
|
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings | null>(null)
|
||||||
const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null)
|
const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null)
|
||||||
|
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
|
||||||
|
const [savingTvSettings, setSavingTvSettings] = useState(false)
|
||||||
const collectorTableRegionRef = useRef<HTMLDivElement | null>(null)
|
const collectorTableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [collectorTableHeight, setCollectorTableHeight] = useState(360)
|
const [collectorTableHeight, setCollectorTableHeight] = useState(360)
|
||||||
const [systemForm] = Form.useForm<SystemSettings>()
|
const [systemForm] = Form.useForm<SystemSettings>()
|
||||||
@@ -91,6 +119,7 @@ function Settings() {
|
|||||||
setSystemSettings(response.data.system)
|
setSystemSettings(response.data.system)
|
||||||
setNotificationSettings(response.data.notifications)
|
setNotificationSettings(response.data.notifications)
|
||||||
setSecuritySettings(response.data.security)
|
setSecuritySettings(response.data.security)
|
||||||
|
setTvSettings(response.data.tv || null)
|
||||||
setCollectors(response.data.collectors || [])
|
setCollectors(response.data.collectors || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('获取系统配置失败')
|
message.error('获取系统配置失败')
|
||||||
@@ -175,6 +204,100 @@ function Settings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateTvSetting = <K extends keyof TVSettings>(field: K, value: TVSettings[K]) => {
|
||||||
|
setTvSettings((prev) => (prev ? { ...prev, [field]: value } : prev))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTvSourceField = <K extends keyof TVStreamSource>(
|
||||||
|
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 = [
|
const collectorColumns = [
|
||||||
{
|
{
|
||||||
title: '数据源',
|
title: '数据源',
|
||||||
@@ -274,6 +397,133 @@ function Settings() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const tvSourceColumns = [
|
||||||
|
{
|
||||||
|
title: '频道',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
width: 220,
|
||||||
|
render: (_: string, record: TVStreamSource) => (
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
<Input value={record.name} onChange={(event) => updateTvSourceField(record.id, 'name', event.target.value)} />
|
||||||
|
<Input
|
||||||
|
value={record.provider}
|
||||||
|
placeholder="提供方"
|
||||||
|
onChange={(event) => updateTvSourceField(record.id, 'provider', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '区域 / 语言',
|
||||||
|
key: 'locale',
|
||||||
|
width: 160,
|
||||||
|
render: (_: unknown, record: TVStreamSource) => (
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
<Input
|
||||||
|
value={record.region}
|
||||||
|
placeholder="区域"
|
||||||
|
onChange={(event) => updateTvSourceField(record.id, 'region', event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={record.language}
|
||||||
|
placeholder="语言"
|
||||||
|
onChange={(event) => updateTvSourceField(record.id, 'language', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'source_type',
|
||||||
|
key: 'source_type',
|
||||||
|
width: 120,
|
||||||
|
render: (value: TVStreamSource['source_type'], record: TVStreamSource) => (
|
||||||
|
<Select
|
||||||
|
value={value}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
onChange={(nextValue) => updateTvSourceField(record.id, 'source_type', nextValue)}
|
||||||
|
options={[
|
||||||
|
{ value: 'iframe', label: 'iframe' },
|
||||||
|
{ value: 'hls', label: 'hls' },
|
||||||
|
{ value: 'video', label: 'video' },
|
||||||
|
{ value: 'youtube', label: 'youtube' },
|
||||||
|
{ value: 'external', label: 'external' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '播放地址',
|
||||||
|
key: 'urls',
|
||||||
|
width: 320,
|
||||||
|
render: (_: unknown, record: TVStreamSource) => (
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
<Input
|
||||||
|
value={record.embed_url}
|
||||||
|
placeholder="嵌入地址 / iframe 地址"
|
||||||
|
onChange={(event) => updateTvSourceField(record.id, 'embed_url', event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={record.stream_url}
|
||||||
|
placeholder="流地址 / HLS 地址"
|
||||||
|
onChange={(event) => updateTvSourceField(record.id, 'stream_url', event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={record.youtube_video_id}
|
||||||
|
placeholder="YouTube 视频 ID(可选)"
|
||||||
|
onChange={(event) => updateTvSourceField(record.id, 'youtube_video_id', event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={record.youtube_channel}
|
||||||
|
placeholder="YouTube 频道 Handle / URL(可选)"
|
||||||
|
onChange={(event) => updateTvSourceField(record.id, 'youtube_channel', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '官网',
|
||||||
|
dataIndex: 'homepage_url',
|
||||||
|
key: 'homepage_url',
|
||||||
|
width: 220,
|
||||||
|
render: (value: string, record: TVStreamSource) => (
|
||||||
|
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'homepage_url', event.target.value)} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
key: 'status',
|
||||||
|
width: 110,
|
||||||
|
render: (_: unknown, record: TVStreamSource) => (
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
<Switch checked={record.is_enabled} onChange={(checked) => updateTvSourceField(record.id, 'is_enabled', checked)} />
|
||||||
|
<Switch checked={record.is_fallback} onChange={(checked) => updateTvSourceField(record.id, 'is_fallback', checked)} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'notes',
|
||||||
|
key: 'notes',
|
||||||
|
width: 220,
|
||||||
|
render: (value: string, record: TVStreamSource) => (
|
||||||
|
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'notes', event.target.value)} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 90,
|
||||||
|
fixed: 'right' as const,
|
||||||
|
render: (_: unknown, record: TVStreamSource) => (
|
||||||
|
<Button danger onClick={() => removeTvSource(record.id)} disabled={record.id === tvSettings?.default_source_id}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const tabItems = [
|
const tabItems = [
|
||||||
{
|
{
|
||||||
key: 'system',
|
key: 'system',
|
||||||
@@ -356,6 +606,58 @@ function Settings() {
|
|||||||
</SettingsPanel>
|
</SettingsPanel>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'tv',
|
||||||
|
label: '电视直播',
|
||||||
|
children: (
|
||||||
|
<div className="settings-pane">
|
||||||
|
<Card className="settings-panel-card settings-panel-card--table" loading={loading}>
|
||||||
|
<div className="settings-panel-scroll" style={{ display: 'grid', gap: 16 }}>
|
||||||
|
<div className="settings-tv-toolbar">
|
||||||
|
<div className="settings-tv-toolbar__controls">
|
||||||
|
<div className="settings-tv-field">
|
||||||
|
<Text type="secondary">默认直播源</Text>
|
||||||
|
<Select
|
||||||
|
value={tvSettings?.default_source_id}
|
||||||
|
style={{ minWidth: 260 }}
|
||||||
|
options={(tvSettings?.sources || []).map((source) => ({
|
||||||
|
value: source.id,
|
||||||
|
label: source.name,
|
||||||
|
}))}
|
||||||
|
onChange={(value) => updateTvSetting('default_source_id', value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-tv-field">
|
||||||
|
<Text type="secondary">自动回退</Text>
|
||||||
|
<Switch
|
||||||
|
checked={tvSettings?.auto_fallback || false}
|
||||||
|
onChange={(checked) => updateTvSetting('auto_fallback', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="settings-tv-toolbar__actions">
|
||||||
|
<Button onClick={addTvSource}>新增直播源</Button>
|
||||||
|
<Button type="primary" loading={savingTvSettings} onClick={saveTvSettings}>
|
||||||
|
保存电视直播配置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-scroll-region data-source-table-region">
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={tvSourceColumns}
|
||||||
|
dataSource={tvSettings?.sources || []}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 1500, y: 420 }}
|
||||||
|
tableLayout="fixed"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'collectors',
|
key: 'collectors',
|
||||||
label: '采集调度',
|
label: '采集调度',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "planet"
|
name = "planet"
|
||||||
version = "0.25.3"
|
version = "0.26.0"
|
||||||
description = "智能星球计划 - 态势感知系统"
|
description = "智能星球计划 - 态势感知系统"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
Reference in New Issue
Block a user