Upload 23 files
Browse files- Dockerfile +27 -1
- app.py +1273 -0
- cache_manager.py +503 -0
- config.py +153 -0
- proxy_handler.py +308 -0
- requirements.txt +10 -0
- static/admin-login.html +519 -0
- static/admin.html +1321 -0
- static/css/style.css +1157 -0
- static/index.html +127 -0
- static/js/auth.js +339 -0
- static/js/common.js +78 -0
- static/js/downloader.js +124 -0
- static/js/hls.min.js +0 -0
- static/js/router.js +215 -0
- static/js/user-data-sync.js +218 -0
- static/templates/api-test.html +104 -0
- static/templates/cache.html +1006 -0
- static/templates/channels.html +153 -0
- static/templates/epg.html +0 -0
- static/templates/player.html +2566 -0
- user_manager.py +556 -0
- utils.py +343 -0
Dockerfile
CHANGED
|
@@ -1 +1,27 @@
|
|
| 1 |
-
FROM
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
RUN apt-get update && apt-get install -y \
|
| 4 |
+
ffmpeg \
|
| 5 |
+
curl \
|
| 6 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
+
|
| 8 |
+
WORKDIR /app
|
| 9 |
+
|
| 10 |
+
COPY requirements.txt .
|
| 11 |
+
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
RUN mkdir -p /tmp/cache/epg /tmp/cache/meta /app/static
|
| 17 |
+
|
| 18 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 19 |
+
CACHE_DIR=/tmp/cache \
|
| 20 |
+
PYTHONDONTWRITEBYTECODE=1
|
| 21 |
+
|
| 22 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
| 23 |
+
CMD curl -f http://localhost:7860/health || exit 1
|
| 24 |
+
|
| 25 |
+
EXPOSE 7860
|
| 26 |
+
|
| 27 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1", "--log-level", "error"]
|
app.py
ADDED
|
@@ -0,0 +1,1273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import json
|
| 3 |
+
import secrets
|
| 4 |
+
import asyncio
|
| 5 |
+
import httpx
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
from fastapi import FastAPI, Request, Header, Depends, HTTPException, status
|
| 9 |
+
from fastapi.responses import JSONResponse, Response, FileResponse, StreamingResponse
|
| 10 |
+
from fastapi.staticfiles import StaticFiles
|
| 11 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
+
from fastapi.middleware.gzip import GZipMiddleware
|
| 13 |
+
from pydantic import BaseModel
|
| 14 |
+
from typing import Optional, List
|
| 15 |
+
from config import Config
|
| 16 |
+
from cache_manager import cache
|
| 17 |
+
from user_manager import user_manager, User, AVAILABLE_BADGES
|
| 18 |
+
from proxy_handler import (
|
| 19 |
+
proxy_media,
|
| 20 |
+
proxy_live_stream_direct,
|
| 21 |
+
proxy_playback_stream,
|
| 22 |
+
get_live_m3u8_url
|
| 23 |
+
)
|
| 24 |
+
from utils import get_auth, get_channels, get_jst_date, fetch_epg, get_all_epg
|
| 25 |
+
|
| 26 |
+
app = FastAPI(
|
| 27 |
+
title=Config.APP_NAME,
|
| 28 |
+
version=Config.APP_VERSION,
|
| 29 |
+
description=Config.APP_DESCRIPTION
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
app.add_middleware(
|
| 33 |
+
CORSMiddleware,
|
| 34 |
+
allow_origins=["*"],
|
| 35 |
+
allow_credentials=True,
|
| 36 |
+
allow_methods=["*"],
|
| 37 |
+
allow_headers=["*"],
|
| 38 |
+
expose_headers=["Content-Length", "Content-Range", "Accept-Ranges", "Content-Disposition"]
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
if Config.ENABLE_GZIP:
|
| 42 |
+
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
| 43 |
+
|
| 44 |
+
static_path = Path(__file__).parent / "static"
|
| 45 |
+
if static_path.exists():
|
| 46 |
+
app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
|
| 47 |
+
|
| 48 |
+
admin_tokens = {}
|
| 49 |
+
|
| 50 |
+
def create_admin_token() -> str:
|
| 51 |
+
token = secrets.token_urlsafe(32)
|
| 52 |
+
expiry = datetime.now() + timedelta(hours=24)
|
| 53 |
+
admin_tokens[token] = expiry
|
| 54 |
+
|
| 55 |
+
return token
|
| 56 |
+
|
| 57 |
+
def verify_admin_token(token: str) -> bool:
|
| 58 |
+
if not token:
|
| 59 |
+
return False
|
| 60 |
+
|
| 61 |
+
now = datetime.now()
|
| 62 |
+
expired = [t for t, exp in admin_tokens.items() if exp < now]
|
| 63 |
+
for t in expired:
|
| 64 |
+
del admin_tokens[t]
|
| 65 |
+
|
| 66 |
+
if token not in admin_tokens:
|
| 67 |
+
return False
|
| 68 |
+
|
| 69 |
+
expiry = admin_tokens[token]
|
| 70 |
+
now = datetime.now()
|
| 71 |
+
|
| 72 |
+
if now > expiry:
|
| 73 |
+
del admin_tokens[token]
|
| 74 |
+
return False
|
| 75 |
+
|
| 76 |
+
return True
|
| 77 |
+
|
| 78 |
+
def get_admin_token(authorization: Optional[str]) -> Optional[str]:
|
| 79 |
+
if not authorization:
|
| 80 |
+
return None
|
| 81 |
+
|
| 82 |
+
if authorization.startswith("Bearer "):
|
| 83 |
+
return authorization[7:]
|
| 84 |
+
|
| 85 |
+
return authorization
|
| 86 |
+
|
| 87 |
+
def get_current_admin_token(authorization: Optional[str] = Header(None)) -> str:
|
| 88 |
+
token = get_admin_token(authorization)
|
| 89 |
+
|
| 90 |
+
if not token:
|
| 91 |
+
raise HTTPException(
|
| 92 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 93 |
+
detail="No token provided"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
if not verify_admin_token(token):
|
| 97 |
+
raise HTTPException(
|
| 98 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 99 |
+
detail="Invalid or expired token"
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
return token
|
| 103 |
+
|
| 104 |
+
class PasswordVerify(BaseModel):
|
| 105 |
+
username: str
|
| 106 |
+
password_hash: str
|
| 107 |
+
|
| 108 |
+
class AdminLogin(BaseModel):
|
| 109 |
+
username: str
|
| 110 |
+
password_hash: str
|
| 111 |
+
|
| 112 |
+
class CreateUserRequest(BaseModel):
|
| 113 |
+
username: str
|
| 114 |
+
password: Optional[str] = None
|
| 115 |
+
expires_days: Optional[int] = None
|
| 116 |
+
notes: str = ""
|
| 117 |
+
badge: Optional[str] = None
|
| 118 |
+
is_admin: bool = False
|
| 119 |
+
|
| 120 |
+
class ExtendExpiryRequest(BaseModel):
|
| 121 |
+
days: int
|
| 122 |
+
|
| 123 |
+
class SetBadgeRequest(BaseModel):
|
| 124 |
+
badge: Optional[str] = None
|
| 125 |
+
|
| 126 |
+
class UserSettings(BaseModel):
|
| 127 |
+
favorite_channels: Optional[List[str]] = None
|
| 128 |
+
playback_history: Optional[dict] = None
|
| 129 |
+
program_reminders: Optional[List[dict]] = None
|
| 130 |
+
download_concurrency: Optional[int] = None
|
| 131 |
+
batch_download_concurrency: Optional[int] = None
|
| 132 |
+
fab_position: Optional[dict] = None
|
| 133 |
+
other_settings: Optional[dict] = None
|
| 134 |
+
|
| 135 |
+
@app.middleware("http")
|
| 136 |
+
async def protocol_middleware(request: Request, call_next):
|
| 137 |
+
forwarded_proto = request.headers.get('X-Forwarded-Proto', '')
|
| 138 |
+
forwarded_host = request.headers.get('X-Forwarded-Host', '')
|
| 139 |
+
forwarded_port = request.headers.get('X-Forwarded-Port', '')
|
| 140 |
+
|
| 141 |
+
if forwarded_proto:
|
| 142 |
+
request.scope['scheme'] = forwarded_proto
|
| 143 |
+
|
| 144 |
+
if forwarded_host:
|
| 145 |
+
port = 443 if forwarded_proto == 'https' else 80
|
| 146 |
+
if forwarded_port:
|
| 147 |
+
try:
|
| 148 |
+
port = int(forwarded_port)
|
| 149 |
+
except:
|
| 150 |
+
pass
|
| 151 |
+
request.scope['server'] = (forwarded_host, port)
|
| 152 |
+
|
| 153 |
+
response = await call_next(request)
|
| 154 |
+
return response
|
| 155 |
+
|
| 156 |
+
@app.middleware("http")
|
| 157 |
+
async def performance_middleware(request: Request, call_next):
|
| 158 |
+
start_time = time.time()
|
| 159 |
+
response = await call_next(request)
|
| 160 |
+
process_time = int((time.time() - start_time) * 1000)
|
| 161 |
+
response.headers["X-Response-Time"] = f"{process_time}ms"
|
| 162 |
+
|
| 163 |
+
if request.url.path.startswith('/static/'):
|
| 164 |
+
response.headers['Cache-Control'] = 'public, max-age=86400'
|
| 165 |
+
|
| 166 |
+
if request.url.path.startswith('/api/') or request.url.path.startswith('/live/') or request.url.path.startswith('/vod/'):
|
| 167 |
+
response.headers['Access-Control-Allow-Origin'] = '*'
|
| 168 |
+
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS, DELETE'
|
| 169 |
+
response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type, Range'
|
| 170 |
+
|
| 171 |
+
return response
|
| 172 |
+
|
| 173 |
+
@app.get("/")
|
| 174 |
+
async def root():
|
| 175 |
+
html_path = Path(__file__).parent / "static" / "index.html"
|
| 176 |
+
if html_path.exists():
|
| 177 |
+
return FileResponse(html_path)
|
| 178 |
+
return {"message": "Frontend not found"}
|
| 179 |
+
|
| 180 |
+
@app.get("/channels")
|
| 181 |
+
async def channels_page():
|
| 182 |
+
return await root()
|
| 183 |
+
|
| 184 |
+
@app.get("/player")
|
| 185 |
+
async def player_page():
|
| 186 |
+
return await root()
|
| 187 |
+
|
| 188 |
+
@app.get("/epg")
|
| 189 |
+
async def epg_page():
|
| 190 |
+
return await root()
|
| 191 |
+
|
| 192 |
+
@app.get("/cache")
|
| 193 |
+
async def cache_page():
|
| 194 |
+
return await root()
|
| 195 |
+
|
| 196 |
+
@app.get("/api-test")
|
| 197 |
+
async def api_test_page():
|
| 198 |
+
return await root()
|
| 199 |
+
|
| 200 |
+
@app.get("/admin")
|
| 201 |
+
async def admin_page():
|
| 202 |
+
html_path = Path(__file__).parent / "static" / "admin.html"
|
| 203 |
+
if html_path.exists():
|
| 204 |
+
return FileResponse(html_path)
|
| 205 |
+
return {"message": "Admin page not found"}
|
| 206 |
+
|
| 207 |
+
@app.get("/admin/login")
|
| 208 |
+
async def admin_login_page():
|
| 209 |
+
html_path = Path(__file__).parent / "static" / "admin-login.html"
|
| 210 |
+
if html_path.exists():
|
| 211 |
+
return FileResponse(html_path)
|
| 212 |
+
return {"message": "Admin login page not found"}
|
| 213 |
+
|
| 214 |
+
@app.post("/api/verify-password")
|
| 215 |
+
async def verify_password(data: PasswordVerify):
|
| 216 |
+
try:
|
| 217 |
+
# ✅ 检查是否是配置文件中的管理员
|
| 218 |
+
if (data.username == Config.ADMIN_USERNAME and
|
| 219 |
+
data.password_hash == Config.ADMIN_PASSWORD_HASH):
|
| 220 |
+
|
| 221 |
+
return {
|
| 222 |
+
"success": True,
|
| 223 |
+
"message": "Admin login successful",
|
| 224 |
+
"user": {
|
| 225 |
+
"username": data.username,
|
| 226 |
+
"is_admin": True, # ✅ 配置文件管理员
|
| 227 |
+
"badge": None
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
# ✅ 检查数据库中的用户
|
| 232 |
+
if data.username and user_manager.verify_user(data.username, data.password_hash):
|
| 233 |
+
user = user_manager.get_user(data.username)
|
| 234 |
+
|
| 235 |
+
if not user:
|
| 236 |
+
return {"success": False, "message": "User not found"}
|
| 237 |
+
|
| 238 |
+
user_data = user_manager.get_user_data(data.username)
|
| 239 |
+
|
| 240 |
+
return {
|
| 241 |
+
"success": True,
|
| 242 |
+
"message": "User login successful",
|
| 243 |
+
"user": {
|
| 244 |
+
"username": data.username,
|
| 245 |
+
"is_admin": user.is_admin, # ✅ 从数据库读取 is_admin 字段
|
| 246 |
+
"badge": user.badge if user and user.badge else None
|
| 247 |
+
},
|
| 248 |
+
"user_data": user_data
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
return {"success": False, "message": "Invalid username or password"}
|
| 252 |
+
|
| 253 |
+
except Exception as e:
|
| 254 |
+
return JSONResponse(
|
| 255 |
+
content={"success": False, "message": str(e)},
|
| 256 |
+
status_code=500
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
@app.get("/api/badges")
|
| 260 |
+
async def get_badges():
|
| 261 |
+
return {
|
| 262 |
+
"success": True,
|
| 263 |
+
"badges": AVAILABLE_BADGES
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
@app.post("/api/admin/login")
|
| 267 |
+
async def admin_login(data: AdminLogin):
|
| 268 |
+
try:
|
| 269 |
+
if (data.username == Config.ADMIN_USERNAME and
|
| 270 |
+
data.password_hash == Config.ADMIN_PASSWORD_HASH):
|
| 271 |
+
|
| 272 |
+
token = create_admin_token()
|
| 273 |
+
|
| 274 |
+
return {
|
| 275 |
+
"success": True,
|
| 276 |
+
"token": token,
|
| 277 |
+
"message": "Login successful"
|
| 278 |
+
}
|
| 279 |
+
else:
|
| 280 |
+
return JSONResponse(
|
| 281 |
+
content={"success": False, "message": "Invalid credentials"},
|
| 282 |
+
status_code=401
|
| 283 |
+
)
|
| 284 |
+
except Exception as e:
|
| 285 |
+
return JSONResponse(
|
| 286 |
+
content={"success": False, "message": str(e)},
|
| 287 |
+
status_code=500
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
@app.get("/api/admin/check")
|
| 291 |
+
async def admin_check(authorization: Optional[str] = Header(None)):
|
| 292 |
+
token = get_admin_token(authorization)
|
| 293 |
+
|
| 294 |
+
if token and verify_admin_token(token):
|
| 295 |
+
return {"authenticated": True}
|
| 296 |
+
|
| 297 |
+
return JSONResponse(
|
| 298 |
+
content={"authenticated": False},
|
| 299 |
+
status_code=401
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
@app.get("/api/admin/badges")
|
| 303 |
+
async def admin_get_badges(token: str = Depends(get_current_admin_token)):
|
| 304 |
+
try:
|
| 305 |
+
return {
|
| 306 |
+
"success": True,
|
| 307 |
+
"badges": AVAILABLE_BADGES
|
| 308 |
+
}
|
| 309 |
+
except Exception as e:
|
| 310 |
+
return JSONResponse(
|
| 311 |
+
content={"success": False, "error": str(e)},
|
| 312 |
+
status_code=500
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
@app.get("/api/admin/stats")
|
| 316 |
+
async def admin_stats(token: str = Depends(get_current_admin_token)):
|
| 317 |
+
try:
|
| 318 |
+
stats = user_manager.get_stats()
|
| 319 |
+
return stats
|
| 320 |
+
except Exception as e:
|
| 321 |
+
return JSONResponse(
|
| 322 |
+
content={"error": str(e)},
|
| 323 |
+
status_code=500
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
@app.get("/api/admin/users")
|
| 327 |
+
async def admin_list_users(token: str = Depends(get_current_admin_token)):
|
| 328 |
+
try:
|
| 329 |
+
users = user_manager.list_users()
|
| 330 |
+
|
| 331 |
+
return {
|
| 332 |
+
"success": True,
|
| 333 |
+
"count": len(users),
|
| 334 |
+
"users": [u.dict() for u in users]
|
| 335 |
+
}
|
| 336 |
+
except Exception as e:
|
| 337 |
+
return JSONResponse(
|
| 338 |
+
content={"success": False, "error": str(e)},
|
| 339 |
+
status_code=500
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
@app.post("/api/admin/users")
|
| 343 |
+
async def admin_create_user(data: CreateUserRequest, token: str = Depends(get_current_admin_token)):
|
| 344 |
+
try:
|
| 345 |
+
if len(user_manager.users) >= Config.MAX_USERS:
|
| 346 |
+
return JSONResponse(
|
| 347 |
+
content={"error": f"Maximum {Config.MAX_USERS} users allowed"},
|
| 348 |
+
status_code=400
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
user, plain_password = user_manager.create_user(
|
| 352 |
+
username=data.username,
|
| 353 |
+
password=data.password,
|
| 354 |
+
expires_days=data.expires_days,
|
| 355 |
+
notes=data.notes,
|
| 356 |
+
badge=data.badge,
|
| 357 |
+
is_admin=data.is_admin
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
return {
|
| 361 |
+
"success": True,
|
| 362 |
+
"user": user.dict(),
|
| 363 |
+
"password": plain_password
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
except ValueError as e:
|
| 367 |
+
return JSONResponse(
|
| 368 |
+
content={"error": str(e)},
|
| 369 |
+
status_code=400
|
| 370 |
+
)
|
| 371 |
+
except Exception as e:
|
| 372 |
+
return JSONResponse(
|
| 373 |
+
content={"error": str(e)},
|
| 374 |
+
status_code=500
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
@app.delete("/api/admin/users/{username}")
|
| 378 |
+
async def admin_delete_user(username: str, token: str = Depends(get_current_admin_token)):
|
| 379 |
+
if user_manager.delete_user(username):
|
| 380 |
+
# ✅ 同时删除用户设置
|
| 381 |
+
user_manager.delete_user_settings(username)
|
| 382 |
+
return {"success": True, "message": f"User {username} deleted"}
|
| 383 |
+
|
| 384 |
+
return JSONResponse(
|
| 385 |
+
content={"error": "User not found"},
|
| 386 |
+
status_code=404
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
@app.post("/api/admin/users/{username}/activate")
|
| 390 |
+
async def admin_activate_user(username: str, token: str = Depends(get_current_admin_token)):
|
| 391 |
+
if user_manager.activate_user(username):
|
| 392 |
+
return {"success": True, "message": f"User {username} activated"}
|
| 393 |
+
|
| 394 |
+
return JSONResponse(
|
| 395 |
+
content={"error": "User not found"},
|
| 396 |
+
status_code=404
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
@app.post("/api/admin/users/{username}/deactivate")
|
| 400 |
+
async def admin_deactivate_user(username: str, token: str = Depends(get_current_admin_token)):
|
| 401 |
+
if user_manager.deactivate_user(username):
|
| 402 |
+
return {"success": True, "message": f"User {username} deactivated"}
|
| 403 |
+
|
| 404 |
+
return JSONResponse(
|
| 405 |
+
content={"error": "User not found"},
|
| 406 |
+
status_code=404
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
@app.post("/api/admin/users/{username}/extend")
|
| 410 |
+
async def admin_extend_expiry(username: str, data: ExtendExpiryRequest, token: str = Depends(get_current_admin_token)):
|
| 411 |
+
if user_manager.extend_expiry(username, data.days):
|
| 412 |
+
return {
|
| 413 |
+
"success": True,
|
| 414 |
+
"message": f"Extended {username} expiry by {data.days} days"
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
return JSONResponse(
|
| 418 |
+
content={"error": "User not found"},
|
| 419 |
+
status_code=404
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
+
@app.post("/api/admin/users/{username}/badge")
|
| 423 |
+
async def admin_set_badge(username: str, data: SetBadgeRequest, token: str = Depends(get_current_admin_token)):
|
| 424 |
+
try:
|
| 425 |
+
if user_manager.set_badge(username, data.badge):
|
| 426 |
+
return {
|
| 427 |
+
"success": True,
|
| 428 |
+
"message": f"Badge updated for {username}"
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
return JSONResponse(
|
| 432 |
+
content={"error": "User not found"},
|
| 433 |
+
status_code=404
|
| 434 |
+
)
|
| 435 |
+
except ValueError as e:
|
| 436 |
+
return JSONResponse(
|
| 437 |
+
content={"error": str(e)},
|
| 438 |
+
status_code=400
|
| 439 |
+
)
|
| 440 |
+
except Exception as e:
|
| 441 |
+
return JSONResponse(
|
| 442 |
+
content={"error": str(e)},
|
| 443 |
+
status_code=500
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
# ==================== 用户设置API ====================
|
| 447 |
+
|
| 448 |
+
@app.get("/api/user/{username}/settings")
|
| 449 |
+
async def get_user_settings(username: str):
|
| 450 |
+
"""获取用户设置"""
|
| 451 |
+
print("\n" + "=" * 80)
|
| 452 |
+
print(f"📥 [API] 收到读取请求")
|
| 453 |
+
print(f" URL: /api/user/{username}/settings")
|
| 454 |
+
print(f" 用户名: {username}")
|
| 455 |
+
print("=" * 80)
|
| 456 |
+
|
| 457 |
+
try:
|
| 458 |
+
settings = user_manager.get_user_settings(username)
|
| 459 |
+
|
| 460 |
+
print(f"📤 [API] 返回数据: {list(settings.keys())}")
|
| 461 |
+
print("=" * 80 + "\n")
|
| 462 |
+
|
| 463 |
+
return {
|
| 464 |
+
"success": True,
|
| 465 |
+
"settings": settings
|
| 466 |
+
}
|
| 467 |
+
except Exception as e:
|
| 468 |
+
print(f"❌ [API] 异常: {e}")
|
| 469 |
+
import traceback
|
| 470 |
+
traceback.print_exc()
|
| 471 |
+
print("=" * 80 + "\n")
|
| 472 |
+
|
| 473 |
+
return JSONResponse(
|
| 474 |
+
content={"success": False, "error": str(e)},
|
| 475 |
+
status_code=500
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
# ==================== 用户数据同步接口(内部使用)====================
|
| 479 |
+
class UserDataSync(BaseModel):
|
| 480 |
+
username: str
|
| 481 |
+
data: dict
|
| 482 |
+
|
| 483 |
+
@app.post("/api/user/data/sync")
|
| 484 |
+
async def sync_user_data(payload: UserDataSync):
|
| 485 |
+
"""同步用户数据到 Redis(内部接口)"""
|
| 486 |
+
print(f"📡 [SYNC] 收到用户数据同步请求: {payload.username}")
|
| 487 |
+
print(f" 数据字段: {list(payload.data.keys())}")
|
| 488 |
+
|
| 489 |
+
try:
|
| 490 |
+
success = user_manager.update_user_data(payload.username, payload.data)
|
| 491 |
+
|
| 492 |
+
if success:
|
| 493 |
+
print(f"✅ [SYNC] 用户 {payload.username} 数据同步成功")
|
| 494 |
+
return {
|
| 495 |
+
"success": True,
|
| 496 |
+
"message": "数据已实时同步到Redis"
|
| 497 |
+
}
|
| 498 |
+
else:
|
| 499 |
+
print(f"❌ [SYNC] 用户 {payload.username} 不存在")
|
| 500 |
+
return JSONResponse(
|
| 501 |
+
content={"success": False, "error": "用户不存在"},
|
| 502 |
+
status_code=404
|
| 503 |
+
)
|
| 504 |
+
except Exception as e:
|
| 505 |
+
print(f"❌ [SYNC] 同步失败: {e}")
|
| 506 |
+
import traceback
|
| 507 |
+
traceback.print_exc()
|
| 508 |
+
return JSONResponse(
|
| 509 |
+
content={"success": False, "error": str(e)},
|
| 510 |
+
status_code=500
|
| 511 |
+
)
|
| 512 |
+
|
| 513 |
+
# ==================== 用户行为跟踪接口 ====================
|
| 514 |
+
class UserBehaviorLog(BaseModel):
|
| 515 |
+
username: str
|
| 516 |
+
action: str # 'play', 'download', 'favorite', 'search', 'setting_change', etc.
|
| 517 |
+
data: dict # 相关数据
|
| 518 |
+
|
| 519 |
+
@app.post("/api/user/behavior/track")
|
| 520 |
+
async def track_user_behavior(payload: UserBehaviorLog):
|
| 521 |
+
"""实时跟踪用户行为并保存到Redis"""
|
| 522 |
+
print(f"📊 [BEHAVIOR] 跟踪用户行为: {payload.username} - {payload.action}")
|
| 523 |
+
|
| 524 |
+
try:
|
| 525 |
+
# 获取当前用户数据
|
| 526 |
+
user_data = user_manager.get_user_data(payload.username)
|
| 527 |
+
if not user_data:
|
| 528 |
+
return JSONResponse(
|
| 529 |
+
content={"success": False, "error": "用户不存在"},
|
| 530 |
+
status_code=404
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
# 根据行为类型更新相应数据
|
| 534 |
+
update_data = {}
|
| 535 |
+
|
| 536 |
+
if payload.action == 'play':
|
| 537 |
+
# 更新播放历史
|
| 538 |
+
playback_history = user_data.get('playback_history', [])
|
| 539 |
+
playback_entry = {
|
| 540 |
+
'timestamp': datetime.now().isoformat(),
|
| 541 |
+
'channel_id': payload.data.get('channel_id'),
|
| 542 |
+
'channel_name': payload.data.get('channel_name'),
|
| 543 |
+
'duration': payload.data.get('duration', 0)
|
| 544 |
+
}
|
| 545 |
+
playback_history.insert(0, playback_entry)
|
| 546 |
+
# 保留最近100条记录
|
| 547 |
+
playback_history = playback_history[:100]
|
| 548 |
+
update_data['playback_history'] = playback_history
|
| 549 |
+
|
| 550 |
+
elif payload.action == 'favorite':
|
| 551 |
+
# 更新收藏频道
|
| 552 |
+
favorite_channels = payload.data.get('favorite_channels', [])
|
| 553 |
+
update_data['favorite_channels'] = favorite_channels
|
| 554 |
+
|
| 555 |
+
elif payload.action == 'setting_change':
|
| 556 |
+
# 更新设置
|
| 557 |
+
for key, value in payload.data.items():
|
| 558 |
+
if key in ['download_concurrency', 'batch_download_concurrency', 'fab_position']:
|
| 559 |
+
update_data[key] = value
|
| 560 |
+
|
| 561 |
+
elif payload.action == 'reminder':
|
| 562 |
+
# 更新节目提醒
|
| 563 |
+
program_reminders = payload.data.get('program_reminders', [])
|
| 564 |
+
update_data['program_reminders'] = program_reminders
|
| 565 |
+
|
| 566 |
+
# 实时保存到Redis
|
| 567 |
+
if update_data:
|
| 568 |
+
success = user_manager.update_user_data(payload.username, update_data)
|
| 569 |
+
if success:
|
| 570 |
+
print(f"✅ [BEHAVIOR] 用户 {payload.username} 行为数据已实时保存")
|
| 571 |
+
return {
|
| 572 |
+
"success": True,
|
| 573 |
+
"message": f"用户行为 '{payload.action}' 已实时保存到Redis"
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
return JSONResponse(
|
| 577 |
+
content={"success": False, "error": "无效的行为数据"},
|
| 578 |
+
status_code=400
|
| 579 |
+
)
|
| 580 |
+
|
| 581 |
+
except Exception as e:
|
| 582 |
+
print(f"❌ [BEHAVIOR] 行为跟踪失败: {e}")
|
| 583 |
+
import traceback
|
| 584 |
+
traceback.print_exc()
|
| 585 |
+
return JSONResponse(
|
| 586 |
+
content={"success": False, "error": str(e)},
|
| 587 |
+
status_code=500
|
| 588 |
+
)
|
| 589 |
+
|
| 590 |
+
@app.get("/health")
|
| 591 |
+
async def health_check():
|
| 592 |
+
stats = cache.get_stats()
|
| 593 |
+
is_valid, missing = Config.validate()
|
| 594 |
+
|
| 595 |
+
return {
|
| 596 |
+
"name": Config.APP_NAME,
|
| 597 |
+
"version": Config.APP_VERSION,
|
| 598 |
+
"description": Config.APP_DESCRIPTION,
|
| 599 |
+
"status": "running" if is_valid else "configuration_error",
|
| 600 |
+
"config_valid": is_valid,
|
| 601 |
+
"missing_config": missing if not is_valid else [],
|
| 602 |
+
"password_protected": len(user_manager.users) > 0,
|
| 603 |
+
"total_users": len(user_manager.users),
|
| 604 |
+
"cache": {
|
| 605 |
+
"storage_type": stats['storage_type'],
|
| 606 |
+
"cid": stats['cid'],
|
| 607 |
+
"auth": stats['auth'],
|
| 608 |
+
"channels": stats['channels'],
|
| 609 |
+
"streams": stats['streams'],
|
| 610 |
+
"epg": stats['epg'],
|
| 611 |
+
"epg_detail": stats.get('epg_detail')
|
| 612 |
+
},
|
| 613 |
+
"features": {
|
| 614 |
+
"streaming": True,
|
| 615 |
+
"download": True,
|
| 616 |
+
"live_recording": True,
|
| 617 |
+
"recording_mode": "Frontend Sequential Recording",
|
| 618 |
+
"user_management": True,
|
| 619 |
+
"admin_features": True,
|
| 620 |
+
"unified_login": True,
|
| 621 |
+
"cache_persistence": stats['storage_type'] in ['redis', 'disk'],
|
| 622 |
+
"user_settings_sync": True,
|
| 623 |
+
"auto_refresh": {
|
| 624 |
+
"cid": "1 day (auto refresh on expire)",
|
| 625 |
+
"auth": "3 hours (auto refresh on expire or 401/403)",
|
| 626 |
+
"storage": stats['storage_type'].upper()
|
| 627 |
+
}
|
| 628 |
+
}
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
@app.get("/api/refresh")
|
| 632 |
+
async def refresh_cache(type: str = "all"):
|
| 633 |
+
cache.clear_cache(type)
|
| 634 |
+
|
| 635 |
+
if type in ['auth', 'all']:
|
| 636 |
+
try:
|
| 637 |
+
await get_auth(force=True)
|
| 638 |
+
message = f"{type.capitalize()} cache cleared and refreshed"
|
| 639 |
+
except Exception as e:
|
| 640 |
+
message = f"{type.capitalize()} cache cleared, but refresh failed: {str(e)}"
|
| 641 |
+
elif type == 'cid':
|
| 642 |
+
try:
|
| 643 |
+
from utils import get_cid
|
| 644 |
+
await get_cid(force=True)
|
| 645 |
+
message = "CID cache cleared and refreshed"
|
| 646 |
+
except Exception as e:
|
| 647 |
+
message = f"CID cache cleared, but refresh failed: {str(e)}"
|
| 648 |
+
else:
|
| 649 |
+
message = f"{type.capitalize()} cache cleared"
|
| 650 |
+
|
| 651 |
+
return {
|
| 652 |
+
"success": True,
|
| 653 |
+
"message": message
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
@app.get("/api/list")
|
| 657 |
+
async def list_channels(request: Request):
|
| 658 |
+
try:
|
| 659 |
+
auth = await get_auth()
|
| 660 |
+
channels = await get_channels(auth)
|
| 661 |
+
|
| 662 |
+
scheme = request.url.scheme
|
| 663 |
+
host = request.url.netloc
|
| 664 |
+
worker_base = f"{scheme}://{host}"
|
| 665 |
+
|
| 666 |
+
rewritten_channels = [
|
| 667 |
+
{
|
| 668 |
+
**ch,
|
| 669 |
+
"playUrl": f"{worker_base}/api/live/{ch['no']}"
|
| 670 |
+
}
|
| 671 |
+
for ch in channels
|
| 672 |
+
]
|
| 673 |
+
|
| 674 |
+
return {
|
| 675 |
+
"success": True,
|
| 676 |
+
"count": len(rewritten_channels),
|
| 677 |
+
"channels": rewritten_channels,
|
| 678 |
+
"cached": cache.get_channels() is not None
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
except Exception as e:
|
| 682 |
+
return JSONResponse(
|
| 683 |
+
content={"success": False, "error": str(e)},
|
| 684 |
+
status_code=500
|
| 685 |
+
)
|
| 686 |
+
|
| 687 |
+
@app.get("/api/epg")
|
| 688 |
+
async def get_epg(vid: str, date: str):
|
| 689 |
+
"""获取单个频道某天的EPG,优先使用缓存"""
|
| 690 |
+
try:
|
| 691 |
+
if not vid or not date:
|
| 692 |
+
return JSONResponse(
|
| 693 |
+
content={"success": False, "error": "Missing vid or date"},
|
| 694 |
+
status_code=400
|
| 695 |
+
)
|
| 696 |
+
|
| 697 |
+
auth = await get_auth()
|
| 698 |
+
|
| 699 |
+
# 直接调用 fetch_epg,它会自动处理缓存
|
| 700 |
+
epg_data = await fetch_epg(vid, date, auth)
|
| 701 |
+
|
| 702 |
+
return {
|
| 703 |
+
"success": True,
|
| 704 |
+
"vid": vid,
|
| 705 |
+
"date": date,
|
| 706 |
+
"count": len(epg_data),
|
| 707 |
+
"epg": epg_data,
|
| 708 |
+
"cached": cache.get_epg(vid, date) is not None
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
except Exception as e:
|
| 712 |
+
return JSONResponse(
|
| 713 |
+
content={"success": False, "error": str(e)},
|
| 714 |
+
status_code=500
|
| 715 |
+
)
|
| 716 |
+
|
| 717 |
+
|
| 718 |
+
@app.get("/api/epg/all")
|
| 719 |
+
async def get_all_epg_data():
|
| 720 |
+
"""获取所有EPG数据,优先使用缓存"""
|
| 721 |
+
try:
|
| 722 |
+
auth = await get_auth()
|
| 723 |
+
|
| 724 |
+
# get_all_epg 会自动处理缓存
|
| 725 |
+
all_epg = await get_all_epg(auth, force=False)
|
| 726 |
+
|
| 727 |
+
total_channels = len(all_epg)
|
| 728 |
+
total_programs = sum(len(programs) for programs in all_epg.values())
|
| 729 |
+
|
| 730 |
+
return {
|
| 731 |
+
"success": True,
|
| 732 |
+
"total_channels": total_channels,
|
| 733 |
+
"total_programs": total_programs,
|
| 734 |
+
"data": all_epg,
|
| 735 |
+
"cached": cache.get_epg('_all_', 'full') is not None
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
except Exception as e:
|
| 739 |
+
return JSONResponse(
|
| 740 |
+
content={"success": False, "error": str(e)},
|
| 741 |
+
status_code=500
|
| 742 |
+
)
|
| 743 |
+
|
| 744 |
+
@app.get("/api/epg/search")
|
| 745 |
+
async def search_epg(keyword: str, days: int = 30):
|
| 746 |
+
"""搜索节目,快速返回结果,后台异步缓存"""
|
| 747 |
+
try:
|
| 748 |
+
if not keyword:
|
| 749 |
+
return JSONResponse(
|
| 750 |
+
content={"success": False, "error": "Missing keyword"},
|
| 751 |
+
status_code=400
|
| 752 |
+
)
|
| 753 |
+
|
| 754 |
+
auth = await get_auth()
|
| 755 |
+
channels_list = await get_channels(auth)
|
| 756 |
+
channel_map = {ch['id']: ch for ch in channels_list}
|
| 757 |
+
|
| 758 |
+
now = datetime.now()
|
| 759 |
+
date_list = []
|
| 760 |
+
for i in range(days + 1):
|
| 761 |
+
date_obj = now - timedelta(days=i)
|
| 762 |
+
date_str = get_jst_date(date_obj)
|
| 763 |
+
date_list.append(date_str)
|
| 764 |
+
|
| 765 |
+
results = []
|
| 766 |
+
keyword_lower = keyword.lower()
|
| 767 |
+
|
| 768 |
+
cache_hits = 0
|
| 769 |
+
cache_misses = 0
|
| 770 |
+
|
| 771 |
+
# 检查是否有全量缓存
|
| 772 |
+
full_cache = cache.get_epg('_all_', 'full')
|
| 773 |
+
|
| 774 |
+
if full_cache:
|
| 775 |
+
# 有全量缓存,直接搜索(最快)
|
| 776 |
+
for channel_id, programs in full_cache.items():
|
| 777 |
+
channel_info = channel_map.get(channel_id)
|
| 778 |
+
if not channel_info:
|
| 779 |
+
continue
|
| 780 |
+
|
| 781 |
+
for program in programs:
|
| 782 |
+
program_time = program.get('time', 0)
|
| 783 |
+
program_date = get_jst_date(datetime.fromtimestamp(program_time))
|
| 784 |
+
|
| 785 |
+
if program_date not in date_list:
|
| 786 |
+
continue
|
| 787 |
+
|
| 788 |
+
title = program.get('title') or program.get('name') or ''
|
| 789 |
+
if keyword_lower in title.lower():
|
| 790 |
+
results.append({
|
| 791 |
+
'channel_id': channel_id,
|
| 792 |
+
'channel_name': channel_info['name'],
|
| 793 |
+
'channel_no': channel_info['no'],
|
| 794 |
+
'program': program,
|
| 795 |
+
'date': program_date
|
| 796 |
+
})
|
| 797 |
+
cache_hits += 1
|
| 798 |
+
else:
|
| 799 |
+
# 没有全量缓存,使用智能搜索策略
|
| 800 |
+
# 策略:只获取和搜索数据,不等待全部缓存完成
|
| 801 |
+
|
| 802 |
+
# 先从已有缓存中搜索
|
| 803 |
+
for channel_id, channel_info in channel_map.items():
|
| 804 |
+
for date_str in date_list:
|
| 805 |
+
cached_epg = cache.get_epg(channel_id, date_str)
|
| 806 |
+
|
| 807 |
+
if cached_epg is not None:
|
| 808 |
+
# 从缓存中搜索
|
| 809 |
+
cache_hits += 1
|
| 810 |
+
for program in cached_epg:
|
| 811 |
+
title = program.get('title') or program.get('name') or ''
|
| 812 |
+
if keyword_lower in title.lower():
|
| 813 |
+
results.append({
|
| 814 |
+
'channel_id': channel_id,
|
| 815 |
+
'channel_name': channel_info['name'],
|
| 816 |
+
'channel_no': channel_info['no'],
|
| 817 |
+
'program': program,
|
| 818 |
+
'date': date_str
|
| 819 |
+
})
|
| 820 |
+
else:
|
| 821 |
+
cache_misses += 1
|
| 822 |
+
|
| 823 |
+
# 如果没有足够的缓存,启动后台任务获取全量数据
|
| 824 |
+
if cache_hits == 0 or cache_misses > cache_hits:
|
| 825 |
+
# 后台异步获取全量EPG并缓存
|
| 826 |
+
asyncio.create_task(background_fetch_all_epg(auth))
|
| 827 |
+
|
| 828 |
+
# 排序结果
|
| 829 |
+
results.sort(key=lambda x: x['program']['time'], reverse=True)
|
| 830 |
+
|
| 831 |
+
return {
|
| 832 |
+
"success": True,
|
| 833 |
+
"keyword": keyword,
|
| 834 |
+
"days": days,
|
| 835 |
+
"total": len(results),
|
| 836 |
+
"results": results,
|
| 837 |
+
"cache_stats": {
|
| 838 |
+
"hits": cache_hits,
|
| 839 |
+
"misses": cache_misses,
|
| 840 |
+
"strategy": "full_cache" if full_cache else "partial_cache",
|
| 841 |
+
"hit_rate": f"{cache_hits * 100 // (cache_hits + cache_misses) if (cache_hits + cache_misses) > 0 else 0}%"
|
| 842 |
+
},
|
| 843 |
+
"message": "后台正在缓存数据,下次搜索会更快" if not full_cache and cache_misses > 0 else None
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
except Exception as e:
|
| 847 |
+
return JSONResponse(
|
| 848 |
+
content={"success": False, "error": str(e)},
|
| 849 |
+
status_code=500
|
| 850 |
+
)
|
| 851 |
+
|
| 852 |
+
|
| 853 |
+
async def background_fetch_all_epg(auth: dict):
|
| 854 |
+
"""后台异步任务:获取全量EPG数据"""
|
| 855 |
+
try:
|
| 856 |
+
# 调用 get_all_epg 来获取并缓存所有数据
|
| 857 |
+
await get_all_epg(auth, force=False)
|
| 858 |
+
except Exception as e:
|
| 859 |
+
# 静默失败,不影响用户体验
|
| 860 |
+
pass
|
| 861 |
+
|
| 862 |
+
@app.get("/api/live/{chid}")
|
| 863 |
+
async def live_stream_info(chid: str, request: Request):
|
| 864 |
+
try:
|
| 865 |
+
auth = await get_auth()
|
| 866 |
+
channels = await get_channels(auth)
|
| 867 |
+
|
| 868 |
+
channel = next((ch for ch in channels if str(ch['no']) == chid), None)
|
| 869 |
+
if not channel:
|
| 870 |
+
return JSONResponse(
|
| 871 |
+
content={
|
| 872 |
+
"success": False,
|
| 873 |
+
"error": f"Channel {chid} not found"
|
| 874 |
+
},
|
| 875 |
+
status_code=404
|
| 876 |
+
)
|
| 877 |
+
|
| 878 |
+
scheme = request.url.scheme
|
| 879 |
+
host = request.url.netloc
|
| 880 |
+
worker_base = f"{scheme}://{host}"
|
| 881 |
+
|
| 882 |
+
upstream_m3u8 = await get_live_m3u8_url(chid, auth)
|
| 883 |
+
|
| 884 |
+
return {
|
| 885 |
+
"success": True,
|
| 886 |
+
"channel": {
|
| 887 |
+
"id": channel['id'],
|
| 888 |
+
"no": channel['no'],
|
| 889 |
+
"name": channel['name']
|
| 890 |
+
},
|
| 891 |
+
"stream": {
|
| 892 |
+
"m3u8": f"{worker_base}/stream/live/{chid}.m3u8",
|
| 893 |
+
"direct": upstream_m3u8
|
| 894 |
+
},
|
| 895 |
+
"info": {
|
| 896 |
+
"protocol": scheme,
|
| 897 |
+
"cached": cache.get_stream(f"live_{chid}") is not None
|
| 898 |
+
}
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
except Exception as e:
|
| 902 |
+
return JSONResponse(
|
| 903 |
+
content={"success": False, "error": str(e)},
|
| 904 |
+
status_code=500
|
| 905 |
+
)
|
| 906 |
+
|
| 907 |
+
@app.get("/stream/live/{chid}.m3u8")
|
| 908 |
+
async def live_stream_m3u8(chid: str, request: Request):
|
| 909 |
+
return await proxy_live_stream_direct(chid, request)
|
| 910 |
+
|
| 911 |
+
@app.get("/api/playback/{path:path}")
|
| 912 |
+
async def playback_stream_info(path: str, request: Request):
|
| 913 |
+
try:
|
| 914 |
+
auth = await get_auth()
|
| 915 |
+
|
| 916 |
+
scheme = request.url.scheme
|
| 917 |
+
host = request.url.netloc
|
| 918 |
+
worker_base = f"{scheme}://{host}"
|
| 919 |
+
|
| 920 |
+
clean_path = path.strip('/')
|
| 921 |
+
if clean_path.startswith('/'):
|
| 922 |
+
clean_path = clean_path[1:]
|
| 923 |
+
|
| 924 |
+
if not clean_path.startswith('query/'):
|
| 925 |
+
if '/' not in clean_path:
|
| 926 |
+
clean_path = f"query/{clean_path}"
|
| 927 |
+
|
| 928 |
+
return {
|
| 929 |
+
"success": True,
|
| 930 |
+
"playback": {
|
| 931 |
+
"path": f"/{clean_path}",
|
| 932 |
+
"m3u8": f"{worker_base}/stream/playback/{clean_path}.m3u8",
|
| 933 |
+
"original_path": path
|
| 934 |
+
},
|
| 935 |
+
"info": {
|
| 936 |
+
"protocol": scheme,
|
| 937 |
+
"type": "playback"
|
| 938 |
+
}
|
| 939 |
+
}
|
| 940 |
+
|
| 941 |
+
except Exception as e:
|
| 942 |
+
return JSONResponse(
|
| 943 |
+
content={"success": False, "error": str(e)},
|
| 944 |
+
status_code=500
|
| 945 |
+
)
|
| 946 |
+
|
| 947 |
+
@app.get("/stream/playback/{path:path}.m3u8")
|
| 948 |
+
async def playback_stream_m3u8(path: str, request: Request):
|
| 949 |
+
return await proxy_playback_stream(path, request)
|
| 950 |
+
|
| 951 |
+
@app.get("/api/download/playback/")
|
| 952 |
+
async def download_playback_by_path(
|
| 953 |
+
request: Request,
|
| 954 |
+
path: str,
|
| 955 |
+
channel: str
|
| 956 |
+
):
|
| 957 |
+
try:
|
| 958 |
+
auth = await get_auth()
|
| 959 |
+
channels = await get_channels(auth)
|
| 960 |
+
target_channel = None
|
| 961 |
+
|
| 962 |
+
for ch in channels:
|
| 963 |
+
if str(ch['no']) == str(channel):
|
| 964 |
+
target_channel = ch
|
| 965 |
+
break
|
| 966 |
+
|
| 967 |
+
if not target_channel:
|
| 968 |
+
raise ValueError(f"频道 {channel} 不存在")
|
| 969 |
+
|
| 970 |
+
clean_path = path.strip()
|
| 971 |
+
if clean_path.startswith('/'):
|
| 972 |
+
clean_path = clean_path[1:]
|
| 973 |
+
if clean_path.startswith('query/'):
|
| 974 |
+
clean_path = clean_path[6:]
|
| 975 |
+
|
| 976 |
+
if clean_path.endswith('.m3u8'):
|
| 977 |
+
clean_path = clean_path[:-6]
|
| 978 |
+
|
| 979 |
+
program_title = "Unknown"
|
| 980 |
+
program_time = None
|
| 981 |
+
found_date = None
|
| 982 |
+
|
| 983 |
+
from datetime import timezone
|
| 984 |
+
JST = timezone(timedelta(hours=9))
|
| 985 |
+
now_jst = datetime.now(JST)
|
| 986 |
+
|
| 987 |
+
for days_ago in range(0, 30):
|
| 988 |
+
check_date_jst = now_jst - timedelta(days=days_ago)
|
| 989 |
+
check_date = check_date_jst.strftime('%Y-%m-%d')
|
| 990 |
+
|
| 991 |
+
try:
|
| 992 |
+
epg_list = await fetch_epg(target_channel['id'], check_date, auth)
|
| 993 |
+
|
| 994 |
+
if not epg_list:
|
| 995 |
+
continue
|
| 996 |
+
|
| 997 |
+
for prog in epg_list:
|
| 998 |
+
if prog.get('path'):
|
| 999 |
+
prog_path = prog['path'].strip()
|
| 1000 |
+
if prog_path.startswith('/'):
|
| 1001 |
+
prog_path = prog_path[1:]
|
| 1002 |
+
if prog_path.startswith('query/'):
|
| 1003 |
+
prog_path = prog_path[6:]
|
| 1004 |
+
if prog_path.endswith('.m3u8'):
|
| 1005 |
+
prog_path = prog_path[:-6]
|
| 1006 |
+
|
| 1007 |
+
if prog_path == clean_path:
|
| 1008 |
+
program_title = prog.get('title') or prog.get('name') or 'Unknown'
|
| 1009 |
+
program_time = datetime.fromtimestamp(prog['time'], tz=JST)
|
| 1010 |
+
found_date = check_date
|
| 1011 |
+
break
|
| 1012 |
+
|
| 1013 |
+
if program_time:
|
| 1014 |
+
break
|
| 1015 |
+
|
| 1016 |
+
except Exception as e:
|
| 1017 |
+
continue
|
| 1018 |
+
|
| 1019 |
+
if not program_time:
|
| 1020 |
+
program_time = now_jst
|
| 1021 |
+
program_title = f"Playback_{target_channel['name']}"
|
| 1022 |
+
|
| 1023 |
+
def clean_text(text):
|
| 1024 |
+
import re
|
| 1025 |
+
text = str(text).strip()
|
| 1026 |
+
|
| 1027 |
+
forbidden_chars = r'[<>:"/\\|?*]'
|
| 1028 |
+
cleaned = re.sub(forbidden_chars, '_', text)
|
| 1029 |
+
|
| 1030 |
+
cleaned = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', cleaned)
|
| 1031 |
+
|
| 1032 |
+
cleaned = re.sub(r'_+', '_', cleaned)
|
| 1033 |
+
|
| 1034 |
+
cleaned = cleaned.strip('_').strip()
|
| 1035 |
+
|
| 1036 |
+
max_length = 150
|
| 1037 |
+
if len(cleaned) > max_length:
|
| 1038 |
+
if '】' in cleaned[:max_length]:
|
| 1039 |
+
pos = cleaned[:max_length].rfind('】')
|
| 1040 |
+
cleaned = cleaned[:pos+1]
|
| 1041 |
+
elif '【' in cleaned[:max_length]:
|
| 1042 |
+
pos = cleaned[:max_length].rfind('【')
|
| 1043 |
+
cleaned = cleaned[:pos]
|
| 1044 |
+
else:
|
| 1045 |
+
cleaned = cleaned[:max_length]
|
| 1046 |
+
|
| 1047 |
+
return cleaned if cleaned else "unknown"
|
| 1048 |
+
|
| 1049 |
+
time_str = program_time.strftime('%Y%m%d_%H%M')
|
| 1050 |
+
channel_name = clean_text(target_channel['name'])
|
| 1051 |
+
program_name = clean_text(program_title)
|
| 1052 |
+
|
| 1053 |
+
filename = f"{time_str}_{channel_name}_{program_name}.ts"
|
| 1054 |
+
|
| 1055 |
+
playback_path = path.strip()
|
| 1056 |
+
if playback_path.startswith('/'):
|
| 1057 |
+
playback_path = playback_path[1:]
|
| 1058 |
+
if not playback_path.startswith('query/'):
|
| 1059 |
+
playback_path = f"query/{playback_path}"
|
| 1060 |
+
|
| 1061 |
+
vod_host = Config.UPSTREAM_HOSTS['vod']
|
| 1062 |
+
from urllib.parse import quote
|
| 1063 |
+
access_token = quote(auth['access_token'])
|
| 1064 |
+
upstream_m3u8 = f"{vod_host}/{playback_path}.m3u8?type=vod&__cross_domain_user={access_token}"
|
| 1065 |
+
|
| 1066 |
+
headers = {
|
| 1067 |
+
'Referer': Config.REQUIRED_REFERER,
|
| 1068 |
+
'User-Agent': 'Mozilla/5.0'
|
| 1069 |
+
}
|
| 1070 |
+
|
| 1071 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 1072 |
+
resp = await client.get(upstream_m3u8, headers=headers)
|
| 1073 |
+
if resp.status_code != 200:
|
| 1074 |
+
raise Exception(f"M3U8获取失败: HTTP {resp.status_code}")
|
| 1075 |
+
m3u8_content = resp.text
|
| 1076 |
+
|
| 1077 |
+
from utils import extract_playlist_url
|
| 1078 |
+
playlist_url = extract_playlist_url(m3u8_content, upstream_m3u8)
|
| 1079 |
+
|
| 1080 |
+
if not playlist_url or playlist_url == upstream_m3u8:
|
| 1081 |
+
playlist_content = m3u8_content
|
| 1082 |
+
playlist_url = upstream_m3u8
|
| 1083 |
+
else:
|
| 1084 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 1085 |
+
resp = await client.get(playlist_url, headers=headers)
|
| 1086 |
+
if resp.status_code != 200:
|
| 1087 |
+
raise Exception(f"播放列表获取失败: HTTP {resp.status_code}")
|
| 1088 |
+
playlist_content = resp.text
|
| 1089 |
+
|
| 1090 |
+
base_url = playlist_url.rsplit('/', 1)[0]
|
| 1091 |
+
ts_urls = []
|
| 1092 |
+
for line in playlist_content.split('\n'):
|
| 1093 |
+
line = line.strip()
|
| 1094 |
+
if line and not line.startswith('#'):
|
| 1095 |
+
ts_urls.append(line if line.startswith('http') else f"{base_url}/{line}")
|
| 1096 |
+
|
| 1097 |
+
if len(ts_urls) == 0:
|
| 1098 |
+
raise Exception("未找到TS分段")
|
| 1099 |
+
|
| 1100 |
+
async def download_concurrent():
|
| 1101 |
+
async def fetch_batch(client, batch, start_idx):
|
| 1102 |
+
tasks = [client.get(url, headers=headers, timeout=60.0) for url in batch]
|
| 1103 |
+
responses = await asyncio.gather(*tasks, return_exceptions=True)
|
| 1104 |
+
|
| 1105 |
+
results = []
|
| 1106 |
+
for i, resp in enumerate(responses):
|
| 1107 |
+
idx = start_idx + i
|
| 1108 |
+
if isinstance(resp, Exception):
|
| 1109 |
+
results.append((idx, None))
|
| 1110 |
+
elif resp.status_code == 200:
|
| 1111 |
+
results.append((idx, resp.content))
|
| 1112 |
+
else:
|
| 1113 |
+
results.append((idx, None))
|
| 1114 |
+
|
| 1115 |
+
return results
|
| 1116 |
+
|
| 1117 |
+
batch_size = 10
|
| 1118 |
+
all_segments = {}
|
| 1119 |
+
|
| 1120 |
+
async with httpx.AsyncClient(
|
| 1121 |
+
timeout=60.0,
|
| 1122 |
+
limits=httpx.Limits(max_keepalive_connections=20, max_connections=30)
|
| 1123 |
+
) as client:
|
| 1124 |
+
for i in range(0, len(ts_urls), batch_size):
|
| 1125 |
+
batch = ts_urls[i:i+batch_size]
|
| 1126 |
+
batch_results = await fetch_batch(client, batch, i)
|
| 1127 |
+
|
| 1128 |
+
for idx, content in batch_results:
|
| 1129 |
+
if content:
|
| 1130 |
+
all_segments[idx] = content
|
| 1131 |
+
|
| 1132 |
+
progress = min(i + batch_size, len(ts_urls))
|
| 1133 |
+
percent = progress * 100 // len(ts_urls)
|
| 1134 |
+
|
| 1135 |
+
for i in range(len(ts_urls)):
|
| 1136 |
+
if i in all_segments:
|
| 1137 |
+
yield all_segments[i]
|
| 1138 |
+
|
| 1139 |
+
from urllib.parse import quote
|
| 1140 |
+
encoded_filename = quote(filename)
|
| 1141 |
+
|
| 1142 |
+
return StreamingResponse(
|
| 1143 |
+
download_concurrent(),
|
| 1144 |
+
media_type="video/mp2t",
|
| 1145 |
+
headers={
|
| 1146 |
+
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
|
| 1147 |
+
"Cache-Control": "no-cache",
|
| 1148 |
+
}
|
| 1149 |
+
)
|
| 1150 |
+
|
| 1151 |
+
except Exception as e:
|
| 1152 |
+
return JSONResponse(
|
| 1153 |
+
content={"success": False, "error": str(e)},
|
| 1154 |
+
status_code=500
|
| 1155 |
+
)
|
| 1156 |
+
|
| 1157 |
+
@app.options("/live/{path:path}")
|
| 1158 |
+
@app.options("/vod/{path:path}")
|
| 1159 |
+
@app.options("/query/{path:path}")
|
| 1160 |
+
@app.options("/stream/{path:path}")
|
| 1161 |
+
@app.options("/api/{path:path}")
|
| 1162 |
+
async def options_handler():
|
| 1163 |
+
return Response(
|
| 1164 |
+
status_code=200,
|
| 1165 |
+
headers={
|
| 1166 |
+
'Access-Control-Allow-Origin': '*',
|
| 1167 |
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, DELETE',
|
| 1168 |
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type, Range',
|
| 1169 |
+
'Access-Control-Max-Age': '3600'
|
| 1170 |
+
}
|
| 1171 |
+
)
|
| 1172 |
+
|
| 1173 |
+
@app.get("/live/{path:path}")
|
| 1174 |
+
async def proxy_live_media(path: str, request: Request):
|
| 1175 |
+
return await proxy_media(request, f"/live/{path}")
|
| 1176 |
+
|
| 1177 |
+
@app.get("/vod/{path:path}")
|
| 1178 |
+
async def proxy_vod_media(path: str, request: Request):
|
| 1179 |
+
return await proxy_media(request, f"/vod/{path}")
|
| 1180 |
+
|
| 1181 |
+
@app.get("/query/{path:path}")
|
| 1182 |
+
async def proxy_query_media(path: str, request: Request):
|
| 1183 |
+
return await proxy_media(request, f"/query/{path}")
|
| 1184 |
+
|
| 1185 |
+
@app.exception_handler(404)
|
| 1186 |
+
async def not_found_handler(request: Request, exc):
|
| 1187 |
+
return JSONResponse(
|
| 1188 |
+
content={"error": "Not Found", "path": request.url.path},
|
| 1189 |
+
status_code=404
|
| 1190 |
+
)
|
| 1191 |
+
|
| 1192 |
+
@app.exception_handler(500)
|
| 1193 |
+
async def server_error_handler(request: Request, exc):
|
| 1194 |
+
return JSONResponse(
|
| 1195 |
+
content={"error": "Internal Server Error", "detail": "An error occurred"},
|
| 1196 |
+
status_code=500
|
| 1197 |
+
)
|
| 1198 |
+
|
| 1199 |
+
@app.on_event("startup")
|
| 1200 |
+
async def startup_event():
|
| 1201 |
+
print("=" * 60)
|
| 1202 |
+
print("🚀 Media Gateway 启动")
|
| 1203 |
+
print("=" * 60)
|
| 1204 |
+
|
| 1205 |
+
# 显示缓存状态
|
| 1206 |
+
stats = cache.get_stats()
|
| 1207 |
+
print(f"📦 存储类型: {stats['storage_type'].upper()}")
|
| 1208 |
+
|
| 1209 |
+
if stats['storage_type'] == 'redis':
|
| 1210 |
+
print(" ✅ Redis 持久化已启用")
|
| 1211 |
+
elif stats['storage_type'] == 'disk':
|
| 1212 |
+
print(f" ✅ 磁盘缓存已启用: {cache.cache_dir}")
|
| 1213 |
+
print(f" 📊 EPG 缓存: {stats.get('epg', 0)} 条")
|
| 1214 |
+
else:
|
| 1215 |
+
print(" ⚠️ 仅使用内存缓存(重启后丢失)")
|
| 1216 |
+
|
| 1217 |
+
# 用户管理状态
|
| 1218 |
+
if user_manager.redis:
|
| 1219 |
+
print("👥 用户数据: Redis 持久化")
|
| 1220 |
+
else:
|
| 1221 |
+
print("👥 用户数据: 内存存储")
|
| 1222 |
+
|
| 1223 |
+
# 配置验证
|
| 1224 |
+
is_valid, missing = Config.validate()
|
| 1225 |
+
if is_valid:
|
| 1226 |
+
print("✅ 配置验证通过")
|
| 1227 |
+
else:
|
| 1228 |
+
print(f"⚠️ 缺少配置: {', '.join(missing)}")
|
| 1229 |
+
|
| 1230 |
+
# 预加载缓存(可选)
|
| 1231 |
+
try:
|
| 1232 |
+
print("🔄 预加载数据...")
|
| 1233 |
+
from utils import get_cid
|
| 1234 |
+
cid = await get_cid()
|
| 1235 |
+
|
| 1236 |
+
auth = await get_auth()
|
| 1237 |
+
|
| 1238 |
+
channels = await get_channels(auth)
|
| 1239 |
+
print(f" ✅ 频道列表: {len(channels)} 个")
|
| 1240 |
+
except Exception as e:
|
| 1241 |
+
print(f" ⚠️ 预加载失败: {e}")
|
| 1242 |
+
|
| 1243 |
+
print("=" * 60)
|
| 1244 |
+
print("✅ 启动完成!")
|
| 1245 |
+
print("=" * 60)
|
| 1246 |
+
|
| 1247 |
+
|
| 1248 |
+
@app.on_event("shutdown")
|
| 1249 |
+
async def shutdown_event():
|
| 1250 |
+
print("\n" + "=" * 60)
|
| 1251 |
+
print("🛑 Media Gateway 关闭中...")
|
| 1252 |
+
print("=" * 60)
|
| 1253 |
+
|
| 1254 |
+
# 保存缓存
|
| 1255 |
+
if cache.storage_type == 'disk':
|
| 1256 |
+
cache._save_to_disk(force=True)
|
| 1257 |
+
print(f"💾 磁盘缓存已保存 ({len(cache.epg)} 条 EPG)")
|
| 1258 |
+
|
| 1259 |
+
# 保存用户数据
|
| 1260 |
+
if not user_manager.redis and hasattr(user_manager, 'users'):
|
| 1261 |
+
print(f"💾 用户数据已保存 ({len(user_manager.users)} 个用户)")
|
| 1262 |
+
|
| 1263 |
+
print("✅ 关闭完成")
|
| 1264 |
+
print("=" * 60)
|
| 1265 |
+
|
| 1266 |
+
if __name__ == "__main__":
|
| 1267 |
+
import uvicorn
|
| 1268 |
+
uvicorn.run(
|
| 1269 |
+
app,
|
| 1270 |
+
host="0.0.0.0",
|
| 1271 |
+
port=7860,
|
| 1272 |
+
log_level="error"
|
| 1273 |
+
)
|
cache_manager.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import json
|
| 3 |
+
import pickle
|
| 4 |
+
import os
|
| 5 |
+
from typing import Any, Optional, Dict
|
| 6 |
+
from datetime import datetime, timedelta
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from config import Config
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
from upstash_redis import Redis
|
| 12 |
+
except ImportError:
|
| 13 |
+
Redis = None
|
| 14 |
+
|
| 15 |
+
class CacheManager:
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self.storage_type = None
|
| 18 |
+
self.cache_dir = None
|
| 19 |
+
self.redis = None
|
| 20 |
+
|
| 21 |
+
# 尝试初始化存储(优先级:Redis > Disk > Memory)
|
| 22 |
+
self._init_storage()
|
| 23 |
+
|
| 24 |
+
# 初始化内存缓存
|
| 25 |
+
self.epg: Dict[str, Dict[str, Any]] = {}
|
| 26 |
+
self.cid: Optional[str] = None
|
| 27 |
+
self.cid_time: float = 0
|
| 28 |
+
self.auth: Optional[Dict[str, Any]] = None
|
| 29 |
+
self.auth_time: float = 0
|
| 30 |
+
self.channels: Optional[list] = None
|
| 31 |
+
self.channels_time: float = 0
|
| 32 |
+
self.stream_info: Dict[str, Dict[str, Any]] = {}
|
| 33 |
+
|
| 34 |
+
# 加载已有缓存
|
| 35 |
+
self._load_cache()
|
| 36 |
+
|
| 37 |
+
def _init_storage(self):
|
| 38 |
+
"""初始化存储(Redis > Disk > Memory)"""
|
| 39 |
+
|
| 40 |
+
# 1. 尝试 Redis
|
| 41 |
+
redis_url = os.getenv('REDIS_URL', '')
|
| 42 |
+
redis_token = os.getenv('REDIS_TOKEN', '')
|
| 43 |
+
|
| 44 |
+
if Redis and redis_url and redis_token:
|
| 45 |
+
try:
|
| 46 |
+
self.redis = Redis(url=redis_url, token=redis_token)
|
| 47 |
+
self.redis.ping()
|
| 48 |
+
self.storage_type = 'redis'
|
| 49 |
+
print("✅ 使用 Redis 持久化存储")
|
| 50 |
+
return
|
| 51 |
+
except Exception as e:
|
| 52 |
+
print(f"⚠️ Redis 连接失败: {e}")
|
| 53 |
+
self.redis = None
|
| 54 |
+
|
| 55 |
+
# 2. 尝试本地磁盘
|
| 56 |
+
cache_dir = Path(os.getenv('CACHE_DIR', '/tmp/cache'))
|
| 57 |
+
|
| 58 |
+
try:
|
| 59 |
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
| 60 |
+
(cache_dir / 'epg').mkdir(exist_ok=True)
|
| 61 |
+
|
| 62 |
+
# 测试写入权限
|
| 63 |
+
test_file = cache_dir / '.test'
|
| 64 |
+
test_file.write_text('test')
|
| 65 |
+
test_file.unlink()
|
| 66 |
+
|
| 67 |
+
self.cache_dir = cache_dir
|
| 68 |
+
self.storage_type = 'disk'
|
| 69 |
+
print(f"✅ 使用本地磁盘缓存: {cache_dir}")
|
| 70 |
+
print(f" 缓存将在容器运行期间保留")
|
| 71 |
+
return
|
| 72 |
+
|
| 73 |
+
except Exception as e:
|
| 74 |
+
print(f"⚠️ 磁盘缓存不可用: {e}")
|
| 75 |
+
|
| 76 |
+
# 3. 降级到内存
|
| 77 |
+
self.storage_type = 'memory'
|
| 78 |
+
print("⚠️ 使用内存缓存(容器重启后丢失)")
|
| 79 |
+
|
| 80 |
+
def _load_cache(self):
|
| 81 |
+
"""启动时加载缓存"""
|
| 82 |
+
if self.storage_type == 'redis':
|
| 83 |
+
self._load_from_redis()
|
| 84 |
+
elif self.storage_type == 'disk':
|
| 85 |
+
self._load_from_disk()
|
| 86 |
+
|
| 87 |
+
def _load_from_redis(self):
|
| 88 |
+
"""从 Redis 加载缓存"""
|
| 89 |
+
try:
|
| 90 |
+
# 加载 CID
|
| 91 |
+
cid_data = self.redis.get('cache:cid')
|
| 92 |
+
if cid_data:
|
| 93 |
+
data = json.loads(cid_data)
|
| 94 |
+
self.cid = data['value']
|
| 95 |
+
self.cid_time = data['time']
|
| 96 |
+
|
| 97 |
+
# 加载 Auth
|
| 98 |
+
auth_data = self.redis.get('cache:auth')
|
| 99 |
+
if auth_data:
|
| 100 |
+
data = json.loads(auth_data)
|
| 101 |
+
self.auth = data['value']
|
| 102 |
+
self.auth_time = data['time']
|
| 103 |
+
|
| 104 |
+
# 加载 Channels
|
| 105 |
+
channels_data = self.redis.get('cache:channels')
|
| 106 |
+
if channels_data:
|
| 107 |
+
data = json.loads(channels_data)
|
| 108 |
+
self.channels = data['value']
|
| 109 |
+
self.channels_time = data['time']
|
| 110 |
+
|
| 111 |
+
print("✅ Redis 缓存加载完成")
|
| 112 |
+
except Exception as e:
|
| 113 |
+
print(f"⚠️ Redis 缓存加载失败: {e}")
|
| 114 |
+
|
| 115 |
+
def _load_from_disk(self):
|
| 116 |
+
"""从磁盘加载缓存"""
|
| 117 |
+
try:
|
| 118 |
+
# 加载 EPG 缓存
|
| 119 |
+
epg_file = self.cache_dir / 'epg_cache.pkl'
|
| 120 |
+
if epg_file.exists():
|
| 121 |
+
with open(epg_file, 'rb') as f:
|
| 122 |
+
self.epg = pickle.load(f)
|
| 123 |
+
print(f"✅ 从磁盘加载 {len(self.epg)} 条 EPG 缓存")
|
| 124 |
+
|
| 125 |
+
# 加载元数据缓存
|
| 126 |
+
meta_file = self.cache_dir / 'meta_cache.json'
|
| 127 |
+
if meta_file.exists():
|
| 128 |
+
with open(meta_file, 'r') as f:
|
| 129 |
+
meta = json.load(f)
|
| 130 |
+
if 'cid' in meta:
|
| 131 |
+
self.cid = meta['cid'].get('value')
|
| 132 |
+
self.cid_time = meta['cid'].get('time', 0)
|
| 133 |
+
if 'auth' in meta:
|
| 134 |
+
self.auth = meta['auth'].get('value')
|
| 135 |
+
self.auth_time = meta['auth'].get('time', 0)
|
| 136 |
+
if 'channels' in meta:
|
| 137 |
+
self.channels = meta['channels'].get('value')
|
| 138 |
+
self.channels_time = meta['channels'].get('time', 0)
|
| 139 |
+
print("✅ 从磁盘加载元数据缓存")
|
| 140 |
+
|
| 141 |
+
except Exception as e:
|
| 142 |
+
print(f"⚠️ 磁盘缓存加载失败: {e}")
|
| 143 |
+
|
| 144 |
+
def _save_to_disk(self, force=False):
|
| 145 |
+
"""保存缓存到磁盘"""
|
| 146 |
+
if self.storage_type != 'disk' or not self.cache_dir:
|
| 147 |
+
return
|
| 148 |
+
|
| 149 |
+
try:
|
| 150 |
+
# 保存 EPG(使用 pickle,快速)
|
| 151 |
+
epg_file = self.cache_dir / 'epg_cache.pkl'
|
| 152 |
+
with open(epg_file, 'wb') as f:
|
| 153 |
+
pickle.dump(self.epg, f, protocol=pickle.HIGHEST_PROTOCOL)
|
| 154 |
+
|
| 155 |
+
# 保存元数据(使用 JSON,可读)
|
| 156 |
+
meta_file = self.cache_dir / 'meta_cache.json'
|
| 157 |
+
meta = {
|
| 158 |
+
'cid': {'value': self.cid, 'time': self.cid_time},
|
| 159 |
+
'auth': {'value': self.auth, 'time': self.auth_time},
|
| 160 |
+
'channels': {'value': self.channels, 'time': self.channels_time}
|
| 161 |
+
}
|
| 162 |
+
with open(meta_file, 'w') as f:
|
| 163 |
+
json.dump(meta, f)
|
| 164 |
+
|
| 165 |
+
if force:
|
| 166 |
+
print(f"💾 磁盘缓存已保存 ({len(self.epg)} 条 EPG)")
|
| 167 |
+
except Exception as e:
|
| 168 |
+
print(f"⚠️ 磁盘缓存保存失败: {e}")
|
| 169 |
+
|
| 170 |
+
# ==================== CID 缓存 ====================
|
| 171 |
+
|
| 172 |
+
def get_cid(self) -> Optional[str]:
|
| 173 |
+
if self.cid and (time.time() - self.cid_time < Config.CACHE_TTL['CID']):
|
| 174 |
+
return self.cid
|
| 175 |
+
|
| 176 |
+
if self.storage_type == 'redis':
|
| 177 |
+
try:
|
| 178 |
+
cid_data = self.redis.get('cache:cid')
|
| 179 |
+
if cid_data:
|
| 180 |
+
data = json.loads(cid_data)
|
| 181 |
+
if time.time() - data['time'] < Config.CACHE_TTL['CID']:
|
| 182 |
+
self.cid = data['value']
|
| 183 |
+
self.cid_time = data['time']
|
| 184 |
+
return self.cid
|
| 185 |
+
except:
|
| 186 |
+
pass
|
| 187 |
+
|
| 188 |
+
return None
|
| 189 |
+
|
| 190 |
+
def set_cid(self, cid: str):
|
| 191 |
+
self.cid = cid
|
| 192 |
+
self.cid_time = time.time()
|
| 193 |
+
|
| 194 |
+
if self.storage_type == 'redis':
|
| 195 |
+
try:
|
| 196 |
+
data = {'value': cid, 'time': self.cid_time}
|
| 197 |
+
self.redis.set('cache:cid', json.dumps(data), ex=Config.CACHE_TTL['CID'])
|
| 198 |
+
except:
|
| 199 |
+
pass
|
| 200 |
+
elif self.storage_type == 'disk':
|
| 201 |
+
self._save_to_disk()
|
| 202 |
+
|
| 203 |
+
# ==================== Auth 缓存 ====================
|
| 204 |
+
|
| 205 |
+
def get_auth(self) -> Optional[Dict[str, Any]]:
|
| 206 |
+
if self.auth and (time.time() - self.auth_time < Config.CACHE_TTL['AUTH']):
|
| 207 |
+
return self.auth
|
| 208 |
+
|
| 209 |
+
if self.storage_type == 'redis':
|
| 210 |
+
try:
|
| 211 |
+
auth_data = self.redis.get('cache:auth')
|
| 212 |
+
if auth_data:
|
| 213 |
+
data = json.loads(auth_data)
|
| 214 |
+
if time.time() - data['time'] < Config.CACHE_TTL['AUTH']:
|
| 215 |
+
self.auth = data['value']
|
| 216 |
+
self.auth_time = data['time']
|
| 217 |
+
return self.auth
|
| 218 |
+
except:
|
| 219 |
+
pass
|
| 220 |
+
|
| 221 |
+
return None
|
| 222 |
+
|
| 223 |
+
def set_auth(self, auth: Dict[str, Any]):
|
| 224 |
+
self.auth = auth
|
| 225 |
+
self.auth_time = time.time()
|
| 226 |
+
|
| 227 |
+
if self.storage_type == 'redis':
|
| 228 |
+
try:
|
| 229 |
+
data = {'value': auth, 'time': self.auth_time}
|
| 230 |
+
self.redis.set('cache:auth', json.dumps(data), ex=Config.CACHE_TTL['AUTH'])
|
| 231 |
+
except:
|
| 232 |
+
pass
|
| 233 |
+
elif self.storage_type == 'disk':
|
| 234 |
+
self._save_to_disk()
|
| 235 |
+
|
| 236 |
+
# ==================== Channels 缓存 ====================
|
| 237 |
+
|
| 238 |
+
def get_channels(self) -> Optional[list]:
|
| 239 |
+
if self.channels and (time.time() - self.channels_time < Config.CACHE_TTL['CHANNELS']):
|
| 240 |
+
return self.channels
|
| 241 |
+
|
| 242 |
+
if self.storage_type == 'redis':
|
| 243 |
+
try:
|
| 244 |
+
channels_data = self.redis.get('cache:channels')
|
| 245 |
+
if channels_data:
|
| 246 |
+
data = json.loads(channels_data)
|
| 247 |
+
if time.time() - data['time'] < Config.CACHE_TTL['CHANNELS']:
|
| 248 |
+
self.channels = data['value']
|
| 249 |
+
self.channels_time = data['time']
|
| 250 |
+
return self.channels
|
| 251 |
+
except:
|
| 252 |
+
pass
|
| 253 |
+
|
| 254 |
+
return None
|
| 255 |
+
|
| 256 |
+
def set_channels(self, channels: list):
|
| 257 |
+
self.channels = channels
|
| 258 |
+
self.channels_time = time.time()
|
| 259 |
+
|
| 260 |
+
if self.storage_type == 'redis':
|
| 261 |
+
try:
|
| 262 |
+
data = {'value': channels, 'time': self.channels_time}
|
| 263 |
+
self.redis.set('cache:channels', json.dumps(data), ex=Config.CACHE_TTL['CHANNELS'])
|
| 264 |
+
except:
|
| 265 |
+
pass
|
| 266 |
+
elif self.storage_type == 'disk':
|
| 267 |
+
self._save_to_disk()
|
| 268 |
+
|
| 269 |
+
# ==================== Stream 缓存 ====================
|
| 270 |
+
|
| 271 |
+
def get_stream(self, key: str) -> Optional[str]:
|
| 272 |
+
if key in self.stream_info:
|
| 273 |
+
cached = self.stream_info[key]
|
| 274 |
+
if time.time() - cached['time'] < Config.CACHE_TTL['STREAM']:
|
| 275 |
+
return cached['url']
|
| 276 |
+
return None
|
| 277 |
+
|
| 278 |
+
def set_stream(self, key: str, url: str):
|
| 279 |
+
self.stream_info[key] = {'url': url, 'time': time.time()}
|
| 280 |
+
|
| 281 |
+
if len(self.stream_info) > Config.MAX_STREAM_CACHE:
|
| 282 |
+
oldest_key = min(self.stream_info.keys(),
|
| 283 |
+
key=lambda k: self.stream_info[k]['time'])
|
| 284 |
+
del self.stream_info[oldest_key]
|
| 285 |
+
|
| 286 |
+
# ==================== EPG 缓存 ====================
|
| 287 |
+
|
| 288 |
+
def get_epg(self, vid: str, date: str) -> Optional[any]:
|
| 289 |
+
key = f"{vid}_{date}"
|
| 290 |
+
|
| 291 |
+
# 检查内存缓存
|
| 292 |
+
if key in self.epg:
|
| 293 |
+
cached = self.epg[key]
|
| 294 |
+
|
| 295 |
+
if vid == '_all_' and date == 'full':
|
| 296 |
+
if time.time() - cached['time'] < Config.CACHE_TTL['EPG_FULL']:
|
| 297 |
+
return cached['data']
|
| 298 |
+
else:
|
| 299 |
+
today = self._get_jst_date()
|
| 300 |
+
ttl = Config.CACHE_TTL['EPG_TODAY'] if date == today else Config.CACHE_TTL['EPG_OTHER']
|
| 301 |
+
|
| 302 |
+
if time.time() - cached['time'] < ttl:
|
| 303 |
+
return cached['data']
|
| 304 |
+
|
| 305 |
+
# 如果使用磁盘缓存,尝试从单独文件加载
|
| 306 |
+
if self.storage_type == 'disk' and vid != '_all_':
|
| 307 |
+
try:
|
| 308 |
+
epg_file = self.cache_dir / 'epg' / f"{key}.pkl"
|
| 309 |
+
if epg_file.exists():
|
| 310 |
+
with open(epg_file, 'rb') as f:
|
| 311 |
+
cached = pickle.load(f)
|
| 312 |
+
|
| 313 |
+
today = self._get_jst_date()
|
| 314 |
+
ttl = Config.CACHE_TTL['EPG_TODAY'] if date == today else Config.CACHE_TTL['EPG_OTHER']
|
| 315 |
+
|
| 316 |
+
if time.time() - cached['time'] < ttl:
|
| 317 |
+
# 加载到内存
|
| 318 |
+
self.epg[key] = cached
|
| 319 |
+
return cached['data']
|
| 320 |
+
except:
|
| 321 |
+
pass
|
| 322 |
+
|
| 323 |
+
return None
|
| 324 |
+
|
| 325 |
+
def set_epg(self, vid: str, date: str, data: any):
|
| 326 |
+
key = f"{vid}_{date}"
|
| 327 |
+
self.epg[key] = {
|
| 328 |
+
'data': data,
|
| 329 |
+
'time': time.time(),
|
| 330 |
+
'date': date,
|
| 331 |
+
'vid': vid
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
# Redis 存储
|
| 335 |
+
if self.storage_type == 'redis':
|
| 336 |
+
# Redis 代码保持不变...
|
| 337 |
+
pass
|
| 338 |
+
|
| 339 |
+
# 磁盘存储
|
| 340 |
+
elif self.storage_type == 'disk':
|
| 341 |
+
# 单独保存每个 EPG 文件(避免频繁写入大文件)
|
| 342 |
+
try:
|
| 343 |
+
epg_file = self.cache_dir / 'epg' / f"{key}.pkl"
|
| 344 |
+
with open(epg_file, 'wb') as f:
|
| 345 |
+
pickle.dump(self.epg[key], f, protocol=pickle.HIGHEST_PROTOCOL)
|
| 346 |
+
except:
|
| 347 |
+
pass
|
| 348 |
+
|
| 349 |
+
# 每 20 条更新一次主缓存文件
|
| 350 |
+
if len(self.epg) % 20 == 0:
|
| 351 |
+
self._save_to_disk()
|
| 352 |
+
|
| 353 |
+
# 清理过期缓存
|
| 354 |
+
if len(self.epg) > Config.MAX_EPG_CACHE:
|
| 355 |
+
self._clean_old_epg()
|
| 356 |
+
|
| 357 |
+
def _clean_old_epg(self):
|
| 358 |
+
"""清理过期 EPG"""
|
| 359 |
+
cutoff_date = (datetime.now() - timedelta(days=Config.CACHE_TTL['EPG_MAX_DAYS'])).strftime('%Y-%m-%d')
|
| 360 |
+
to_delete = [k for k, v in self.epg.items()
|
| 361 |
+
if v.get('date', '') < cutoff_date and not k.startswith('_all_')]
|
| 362 |
+
|
| 363 |
+
for k in to_delete:
|
| 364 |
+
del self.epg[k]
|
| 365 |
+
|
| 366 |
+
# 删除磁盘文件
|
| 367 |
+
if self.storage_type == 'disk':
|
| 368 |
+
try:
|
| 369 |
+
epg_file = self.cache_dir / 'epg' / f"{k}.pkl"
|
| 370 |
+
if epg_file.exists():
|
| 371 |
+
epg_file.unlink()
|
| 372 |
+
except:
|
| 373 |
+
pass
|
| 374 |
+
|
| 375 |
+
def _get_jst_date(self) -> str:
|
| 376 |
+
from datetime import timezone
|
| 377 |
+
jst = timezone(timedelta(hours=9))
|
| 378 |
+
now = datetime.now(jst)
|
| 379 |
+
return now.strftime('%Y-%m-%d')
|
| 380 |
+
|
| 381 |
+
# ==================== 缓存管理 ====================
|
| 382 |
+
|
| 383 |
+
def clear_cache(self, cache_type: str = 'all'):
|
| 384 |
+
if cache_type in ['cid', 'all']:
|
| 385 |
+
self.cid = None
|
| 386 |
+
self.cid_time = 0
|
| 387 |
+
if self.storage_type == 'redis':
|
| 388 |
+
try:
|
| 389 |
+
self.redis.delete('cache:cid')
|
| 390 |
+
except:
|
| 391 |
+
pass
|
| 392 |
+
|
| 393 |
+
if cache_type in ['auth', 'all']:
|
| 394 |
+
self.auth = None
|
| 395 |
+
self.auth_time = 0
|
| 396 |
+
if self.storage_type == 'redis':
|
| 397 |
+
try:
|
| 398 |
+
self.redis.delete('cache:auth')
|
| 399 |
+
except:
|
| 400 |
+
pass
|
| 401 |
+
|
| 402 |
+
if cache_type in ['channels', 'all']:
|
| 403 |
+
self.channels = None
|
| 404 |
+
self.channels_time = 0
|
| 405 |
+
if self.storage_type == 'redis':
|
| 406 |
+
try:
|
| 407 |
+
self.redis.delete('cache:channels')
|
| 408 |
+
except:
|
| 409 |
+
pass
|
| 410 |
+
|
| 411 |
+
if cache_type in ['streams', 'all']:
|
| 412 |
+
self.stream_info.clear()
|
| 413 |
+
|
| 414 |
+
if cache_type in ['epg', 'all']:
|
| 415 |
+
self.epg.clear()
|
| 416 |
+
|
| 417 |
+
# 清理磁盘文件
|
| 418 |
+
if self.storage_type == 'disk' and self.cache_dir:
|
| 419 |
+
try:
|
| 420 |
+
for epg_file in (self.cache_dir / 'epg').glob('*.pkl'):
|
| 421 |
+
epg_file.unlink()
|
| 422 |
+
(self.cache_dir / 'epg_cache.pkl').unlink(missing_ok=True)
|
| 423 |
+
except:
|
| 424 |
+
pass
|
| 425 |
+
|
| 426 |
+
# 保存更改
|
| 427 |
+
if self.storage_type == 'disk':
|
| 428 |
+
self._save_to_disk()
|
| 429 |
+
|
| 430 |
+
def get_stats(self) -> dict:
|
| 431 |
+
return {
|
| 432 |
+
'storage_type': self.storage_type,
|
| 433 |
+
'cid': {
|
| 434 |
+
'cached': self.cid is not None,
|
| 435 |
+
'value': self.cid if self.cid else None,
|
| 436 |
+
'age': f"{int(time.time() - self.cid_time)}s" if self.cid else None,
|
| 437 |
+
'ttl': f"{Config.CACHE_TTL['CID'] - int(time.time() - self.cid_time)}s" if self.cid else None,
|
| 438 |
+
'storage': self.storage_type
|
| 439 |
+
},
|
| 440 |
+
'auth': {
|
| 441 |
+
'cached': self.auth is not None,
|
| 442 |
+
'token': self.auth['access_token'] if self.auth and 'access_token' in self.auth else None,
|
| 443 |
+
'age': f"{int(time.time() - self.auth_time)}s" if self.auth else None,
|
| 444 |
+
'ttl': f"{Config.CACHE_TTL['AUTH'] - int(time.time() - self.auth_time)}s" if self.auth else None,
|
| 445 |
+
'storage': self.storage_type
|
| 446 |
+
},
|
| 447 |
+
'channels': self.channels is not None,
|
| 448 |
+
'streams': len(self.stream_info),
|
| 449 |
+
'epg': len(self.epg),
|
| 450 |
+
'epg_detail': self.get_epg_cache_info() if self.epg else None
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
def get_epg_cache_info(self) -> dict:
|
| 454 |
+
"""获取 EPG 缓存详细信息"""
|
| 455 |
+
info = {
|
| 456 |
+
'total_entries': len(self.epg),
|
| 457 |
+
'by_channel': {},
|
| 458 |
+
'by_date': {},
|
| 459 |
+
'full_cache_available': False
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
# 检查全量缓存
|
| 463 |
+
if '_all__full' in self.epg:
|
| 464 |
+
info['full_cache_available'] = True
|
| 465 |
+
full_cache = self.epg['_all__full']
|
| 466 |
+
info['full_cache_time'] = datetime.fromtimestamp(full_cache['time']).strftime('%Y-%m-%d %H:%M:%S')
|
| 467 |
+
info['full_cache_age'] = f"{int(time.time() - full_cache['time'])}s"
|
| 468 |
+
|
| 469 |
+
# 统计各频道和日期
|
| 470 |
+
for key, value in self.epg.items():
|
| 471 |
+
if key == '_all__full':
|
| 472 |
+
continue
|
| 473 |
+
|
| 474 |
+
vid = value.get('vid', 'unknown')
|
| 475 |
+
date = value.get('date', 'unknown')
|
| 476 |
+
|
| 477 |
+
if vid not in info['by_channel']:
|
| 478 |
+
info['by_channel'][vid] = {'dates': [], 'program_count': 0}
|
| 479 |
+
info['by_channel'][vid]['dates'].append(date)
|
| 480 |
+
info['by_channel'][vid]['program_count'] += len(value.get('data', []))
|
| 481 |
+
|
| 482 |
+
if date not in info['by_date']:
|
| 483 |
+
info['by_date'][date] = {'channels': [], 'program_count': 0}
|
| 484 |
+
info['by_date'][date]['channels'].append(vid)
|
| 485 |
+
info['by_date'][date]['program_count'] += len(value.get('data', []))
|
| 486 |
+
|
| 487 |
+
info['summary'] = {
|
| 488 |
+
'total_channels': len(info['by_channel']),
|
| 489 |
+
'total_dates': len(info['by_date']),
|
| 490 |
+
'total_programs': sum(ch['program_count'] for ch in info['by_channel'].values())
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
return info
|
| 494 |
+
|
| 495 |
+
def __del__(self):
|
| 496 |
+
"""程序退出时保存缓存"""
|
| 497 |
+
if self.storage_type == 'disk':
|
| 498 |
+
self._save_to_disk(force=True)
|
| 499 |
+
print("💾 缓存已保存到磁盘")
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
# 全局单例
|
| 503 |
+
cache = CacheManager()
|
config.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import Dict
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
env_path = Path(__file__).parent / '.env'
|
| 7 |
+
if env_path.exists():
|
| 8 |
+
load_dotenv(dotenv_path=env_path)
|
| 9 |
+
|
| 10 |
+
class Config:
|
| 11 |
+
APP_NAME = os.getenv('APP_NAME', 'Media Gateway')
|
| 12 |
+
APP_DESCRIPTION = os.getenv('APP_DESCRIPTION', 'Streaming Media Service')
|
| 13 |
+
APP_VERSION = os.getenv('APP_VERSION', '1.0.0')
|
| 14 |
+
|
| 15 |
+
API_KEY = os.getenv('API_KEY', '')
|
| 16 |
+
REQUIRED_REFERER = os.getenv('REQUIRED_REFERER', '')
|
| 17 |
+
LIST_LIVES_CID = os.getenv('LIST_LIVES_CID', '')
|
| 18 |
+
|
| 19 |
+
UPSTREAM_HOSTS = {
|
| 20 |
+
'live': os.getenv('UPSTREAM_HOST_LIVE', ''),
|
| 21 |
+
'vod': os.getenv('UPSTREAM_HOST_VOD', '')
|
| 22 |
+
}
|
| 23 |
+
CID_API_URL = os.getenv('CID_API_URL', '')
|
| 24 |
+
LOGIN_API_URL = os.getenv('LOGIN_API_URL', '')
|
| 25 |
+
LIST_API_URL = os.getenv('LIST_API_URL', '')
|
| 26 |
+
EPG_API_URL = os.getenv('EPG_API_URL', '')
|
| 27 |
+
|
| 28 |
+
@classmethod
|
| 29 |
+
@property
|
| 30 |
+
def LIST_API_URL_WITH_EPG(cls):
|
| 31 |
+
if cls.LIST_API_URL:
|
| 32 |
+
return cls.LIST_API_URL.replace('no_epg=1', 'no_epg=0')
|
| 33 |
+
return ''
|
| 34 |
+
|
| 35 |
+
LOGIN_PASSWORD = os.getenv('LOGIN_PASSWORD', '')
|
| 36 |
+
LOGIN_APP_ID = os.getenv('LOGIN_APP_ID', '')
|
| 37 |
+
LOGIN_DEVICE_ID = os.getenv('LOGIN_DEVICE_ID', '')
|
| 38 |
+
|
| 39 |
+
ADMIN_USERNAME = os.getenv('ADMIN_USERNAME', '')
|
| 40 |
+
ADMIN_PASSWORD_HASH = os.getenv('ADMIN_PASSWORD_HASH', '')
|
| 41 |
+
|
| 42 |
+
USER_PASSWORD_EXPIRY_DAYS = int(os.getenv('USER_PASSWORD_EXPIRY_DAYS', '30'))
|
| 43 |
+
MAX_USERS = int(os.getenv('MAX_USERS', '50'))
|
| 44 |
+
REDIS_URL = os.getenv('REDIS_URL', '')
|
| 45 |
+
REDIS_TOKEN = os.getenv('REDIS_TOKEN', '')
|
| 46 |
+
|
| 47 |
+
CACHE_TTL: Dict[str, int] = {
|
| 48 |
+
'CID': int(os.getenv('CACHE_TTL_CID', '86400')),
|
| 49 |
+
'AUTH': int(os.getenv('CACHE_TTL_AUTH', '10800')),
|
| 50 |
+
'CHANNELS': int(os.getenv('CACHE_TTL_CHANNELS', '86400')),
|
| 51 |
+
'STREAM': int(os.getenv('CACHE_TTL_STREAM', '21600')),
|
| 52 |
+
'EPG_TODAY': int(os.getenv('CACHE_TTL_EPG_TODAY', '300')),
|
| 53 |
+
'EPG_OTHER': int(os.getenv('CACHE_TTL_EPG_OTHER', '86400')),
|
| 54 |
+
'EPG_MAX_DAYS': int(os.getenv('CACHE_TTL_EPG_MAX_DAYS', '30')),
|
| 55 |
+
'EPG_FULL': int(os.getenv('CACHE_TTL_EPG_FULL', '3600')),
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
TIMEOUT = int(os.getenv('HTTP_TIMEOUT', '60'))
|
| 59 |
+
CONNECT_TIMEOUT = int(os.getenv('HTTP_CONNECT_TIMEOUT', '10'))
|
| 60 |
+
MAX_STREAM_CACHE = int(os.getenv('MAX_STREAM_CACHE', '200'))
|
| 61 |
+
MAX_EPG_CACHE = int(os.getenv('MAX_EPG_CACHE', '1000'))
|
| 62 |
+
DEBUG = os.getenv('DEBUG', 'false').lower() == 'true'
|
| 63 |
+
|
| 64 |
+
ENABLE_GZIP = os.getenv('ENABLE_GZIP', 'true').lower() == 'true'
|
| 65 |
+
MAX_CONCURRENT_REQUESTS = int(os.getenv('MAX_CONCURRENT_REQUESTS', '100'))
|
| 66 |
+
|
| 67 |
+
@classmethod
|
| 68 |
+
def get_cid_url(cls) -> str:
|
| 69 |
+
return cls.CID_API_URL.replace('{API_KEY}', cls.API_KEY)
|
| 70 |
+
|
| 71 |
+
@classmethod
|
| 72 |
+
def get_login_url(cls, cid: str) -> str:
|
| 73 |
+
return (cls.LOGIN_API_URL
|
| 74 |
+
.replace('{CID}', cid)
|
| 75 |
+
.replace('{PASSWORD}', cls.LOGIN_PASSWORD)
|
| 76 |
+
.replace('{APP_ID}', cls.LOGIN_APP_ID)
|
| 77 |
+
.replace('{DEVICE_ID}', cls.LOGIN_DEVICE_ID))
|
| 78 |
+
|
| 79 |
+
@classmethod
|
| 80 |
+
def get_list_url(cls, uid: str, with_epg: bool = False) -> str:
|
| 81 |
+
if with_epg:
|
| 82 |
+
url = cls.LIST_API_URL.replace('no_epg=1', 'no_epg=0')
|
| 83 |
+
else:
|
| 84 |
+
url = cls.LIST_API_URL
|
| 85 |
+
|
| 86 |
+
url = url.replace('{UPSTREAM_HOST}', cls.UPSTREAM_HOSTS['live'])
|
| 87 |
+
url = url.replace('{CID}', cls.LIST_LIVES_CID)
|
| 88 |
+
url = url.replace('{UID}', uid)
|
| 89 |
+
url = url.replace('{REFERER}', cls.REQUIRED_REFERER)
|
| 90 |
+
return url
|
| 91 |
+
|
| 92 |
+
@classmethod
|
| 93 |
+
def get_epg_url(cls, uid: str, vid: str) -> str:
|
| 94 |
+
url = cls.EPG_API_URL
|
| 95 |
+
url = url.replace('{UPSTREAM_HOST}', cls.UPSTREAM_HOSTS['live'])
|
| 96 |
+
url = url.replace('{CID}', cls.LIST_LIVES_CID)
|
| 97 |
+
url = url.replace('{UID}', uid)
|
| 98 |
+
url = url.replace('{VID}', vid)
|
| 99 |
+
url = url.replace('{REFERER}', cls.REQUIRED_REFERER)
|
| 100 |
+
return url
|
| 101 |
+
|
| 102 |
+
@classmethod
|
| 103 |
+
def validate(cls) -> tuple[bool, list[str]]:
|
| 104 |
+
missing = []
|
| 105 |
+
|
| 106 |
+
required_vars = {
|
| 107 |
+
'API_KEY': cls.API_KEY,
|
| 108 |
+
'REQUIRED_REFERER': cls.REQUIRED_REFERER,
|
| 109 |
+
'LIST_LIVES_CID': cls.LIST_LIVES_CID,
|
| 110 |
+
'UPSTREAM_HOST_LIVE': cls.UPSTREAM_HOSTS['live'],
|
| 111 |
+
'UPSTREAM_HOST_VOD': cls.UPSTREAM_HOSTS['vod'],
|
| 112 |
+
'CID_API_URL': cls.CID_API_URL,
|
| 113 |
+
'LOGIN_API_URL': cls.LOGIN_API_URL,
|
| 114 |
+
'LIST_API_URL': cls.LIST_API_URL,
|
| 115 |
+
'EPG_API_URL': cls.EPG_API_URL,
|
| 116 |
+
'LOGIN_PASSWORD': cls.LOGIN_PASSWORD,
|
| 117 |
+
'LOGIN_APP_ID': cls.LOGIN_APP_ID,
|
| 118 |
+
'LOGIN_DEVICE_ID': cls.LOGIN_DEVICE_ID
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
for var_name, var_value in required_vars.items():
|
| 122 |
+
if not var_value:
|
| 123 |
+
missing.append(var_name)
|
| 124 |
+
|
| 125 |
+
return (len(missing) == 0, missing)
|
| 126 |
+
|
| 127 |
+
@classmethod
|
| 128 |
+
def get_masked_config(cls) -> dict:
|
| 129 |
+
def mask(value: str, show_chars: int = 4) -> str:
|
| 130 |
+
if not value or len(value) <= show_chars:
|
| 131 |
+
return '***'
|
| 132 |
+
return value[:show_chars] + '***'
|
| 133 |
+
|
| 134 |
+
return {
|
| 135 |
+
'APP_NAME': cls.APP_NAME,
|
| 136 |
+
'APP_VERSION': cls.APP_VERSION,
|
| 137 |
+
'API_KEY': mask(cls.API_KEY),
|
| 138 |
+
'REQUIRED_REFERER': mask(cls.REQUIRED_REFERER, 10),
|
| 139 |
+
'LIST_LIVES_CID': mask(cls.LIST_LIVES_CID, 8),
|
| 140 |
+
'UPSTREAM_HOST_LIVE': cls.UPSTREAM_HOSTS['live'],
|
| 141 |
+
'UPSTREAM_HOST_VOD': cls.UPSTREAM_HOSTS['vod'],
|
| 142 |
+
'CID_API_URL': mask(cls.CID_API_URL, 15),
|
| 143 |
+
'LOGIN_API_URL': mask(cls.LOGIN_API_URL, 15),
|
| 144 |
+
'LOGIN_PASSWORD': '***',
|
| 145 |
+
'LOGIN_APP_ID': mask(cls.LOGIN_APP_ID, 8),
|
| 146 |
+
'LOGIN_DEVICE_ID': mask(cls.LOGIN_DEVICE_ID, 8),
|
| 147 |
+
'CACHE_TTL': cls.CACHE_TTL,
|
| 148 |
+
'TIMEOUT': cls.TIMEOUT,
|
| 149 |
+
'CONNECT_TIMEOUT': cls.CONNECT_TIMEOUT,
|
| 150 |
+
'MAX_STREAM_CACHE': cls.MAX_STREAM_CACHE,
|
| 151 |
+
'MAX_EPG_CACHE': cls.MAX_EPG_CACHE,
|
| 152 |
+
'DEBUG': cls.DEBUG
|
| 153 |
+
}
|
proxy_handler.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
from urllib.parse import quote, urlparse
|
| 3 |
+
from fastapi import Request
|
| 4 |
+
from fastapi.responses import Response
|
| 5 |
+
from config import Config
|
| 6 |
+
from cache_manager import cache
|
| 7 |
+
from utils import get_auth, get_channels, rewrite_m3u8, extract_playlist_url
|
| 8 |
+
|
| 9 |
+
def get_client_base_url(request: Request) -> str:
|
| 10 |
+
scheme = request.url.scheme
|
| 11 |
+
netloc = request.url.netloc
|
| 12 |
+
return f"{scheme}://{netloc}"
|
| 13 |
+
|
| 14 |
+
async def get_live_m3u8_url(chid: str, auth: dict, retry_count: int = 0) -> str:
|
| 15 |
+
try:
|
| 16 |
+
channels = await get_channels(auth)
|
| 17 |
+
channel = next((ch for ch in channels if str(ch['no']) == chid), None)
|
| 18 |
+
|
| 19 |
+
if not channel:
|
| 20 |
+
raise ValueError(f"Channel {chid} not found")
|
| 21 |
+
|
| 22 |
+
cache_key = f"live_{chid}"
|
| 23 |
+
playlist_url = cache.get_stream(cache_key)
|
| 24 |
+
|
| 25 |
+
if not playlist_url:
|
| 26 |
+
source_url = (
|
| 27 |
+
f"{auth['vms_host']}{channel['playpath']}.M3U8"
|
| 28 |
+
f"?type=live&__cross_domain_user={quote(auth['access_token'])}"
|
| 29 |
+
)
|
| 30 |
+
source_url = source_url.replace(auth['vms_host'], Config.UPSTREAM_HOSTS['live'])
|
| 31 |
+
|
| 32 |
+
headers = {
|
| 33 |
+
'Referer': Config.REQUIRED_REFERER,
|
| 34 |
+
'User-Agent': 'Mozilla/5.0'
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
async with httpx.AsyncClient(
|
| 38 |
+
timeout=httpx.Timeout(30.0, connect=5.0),
|
| 39 |
+
follow_redirects=True,
|
| 40 |
+
limits=httpx.Limits(max_keepalive_connections=50, max_connections=200)
|
| 41 |
+
) as client:
|
| 42 |
+
main_response = await client.get(source_url, headers=headers)
|
| 43 |
+
|
| 44 |
+
if main_response.status_code in [401, 403] and retry_count < 2:
|
| 45 |
+
new_auth = await get_auth(force=True)
|
| 46 |
+
return await get_live_m3u8_url(chid, new_auth, retry_count + 1)
|
| 47 |
+
|
| 48 |
+
main_response.raise_for_status()
|
| 49 |
+
main_content = main_response.text
|
| 50 |
+
|
| 51 |
+
playlist_url = extract_playlist_url(main_content, source_url)
|
| 52 |
+
if not playlist_url:
|
| 53 |
+
playlist_url = source_url
|
| 54 |
+
|
| 55 |
+
cache.set_stream(cache_key, playlist_url)
|
| 56 |
+
|
| 57 |
+
return playlist_url
|
| 58 |
+
|
| 59 |
+
except httpx.HTTPStatusError as e:
|
| 60 |
+
if e.response.status_code in [401, 403] and retry_count < 2:
|
| 61 |
+
new_auth = await get_auth(force=True)
|
| 62 |
+
return await get_live_m3u8_url(chid, new_auth, retry_count + 1)
|
| 63 |
+
raise e
|
| 64 |
+
|
| 65 |
+
async def proxy_live_stream_direct(chid: str, request: Request) -> Response:
|
| 66 |
+
try:
|
| 67 |
+
auth = await get_auth()
|
| 68 |
+
playlist_url = await get_live_m3u8_url(chid, auth)
|
| 69 |
+
|
| 70 |
+
headers = {
|
| 71 |
+
'Referer': Config.REQUIRED_REFERER,
|
| 72 |
+
'User-Agent': 'Mozilla/5.0'
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
async with httpx.AsyncClient(
|
| 76 |
+
timeout=httpx.Timeout(30.0, connect=5.0),
|
| 77 |
+
follow_redirects=True,
|
| 78 |
+
limits=httpx.Limits(max_keepalive_connections=50, max_connections=200)
|
| 79 |
+
) as client:
|
| 80 |
+
playlist_response = await client.get(playlist_url, headers=headers)
|
| 81 |
+
playlist_response.raise_for_status()
|
| 82 |
+
playlist_content = playlist_response.text
|
| 83 |
+
|
| 84 |
+
parsed = urlparse(playlist_url)
|
| 85 |
+
playlist_path = parsed.path + ('?' + parsed.query if parsed.query else '')
|
| 86 |
+
|
| 87 |
+
worker_base = get_client_base_url(request)
|
| 88 |
+
rewritten = rewrite_m3u8(playlist_content, playlist_path, worker_base)
|
| 89 |
+
|
| 90 |
+
return Response(
|
| 91 |
+
content=rewritten,
|
| 92 |
+
media_type='application/vnd.apple.mpegurl',
|
| 93 |
+
headers={
|
| 94 |
+
'Cache-Control': 'public, max-age=10',
|
| 95 |
+
'Access-Control-Allow-Origin': '*',
|
| 96 |
+
'Access-Control-Allow-Headers': 'Range',
|
| 97 |
+
'Access-Control-Expose-Headers': 'Content-Length, Content-Range'
|
| 98 |
+
}
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
except httpx.HTTPError as e:
|
| 102 |
+
return Response(
|
| 103 |
+
content=f'{{"error": "Upstream error: {str(e)}"}}',
|
| 104 |
+
status_code=502,
|
| 105 |
+
media_type='application/json'
|
| 106 |
+
)
|
| 107 |
+
except Exception as e:
|
| 108 |
+
return Response(
|
| 109 |
+
content=f'{{"error": "{str(e)}"}}',
|
| 110 |
+
status_code=500,
|
| 111 |
+
media_type='application/json'
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
async def proxy_playback_stream(path: str, request: Request, retry_count: int = 0) -> Response:
|
| 115 |
+
try:
|
| 116 |
+
auth = await get_auth()
|
| 117 |
+
|
| 118 |
+
if not path.startswith('query/'):
|
| 119 |
+
path = f"query/{path}"
|
| 120 |
+
|
| 121 |
+
vod_path = path.replace('.m3u8', '')
|
| 122 |
+
cache_key = f"vod_{vod_path.replace('/', '_').replace('=', '')}"
|
| 123 |
+
playlist_url = cache.get_stream(cache_key)
|
| 124 |
+
|
| 125 |
+
if not playlist_url:
|
| 126 |
+
source_url = (
|
| 127 |
+
f"{Config.UPSTREAM_HOSTS['vod']}/{vod_path}.m3u8"
|
| 128 |
+
f"?type=vod&__cross_domain_user={quote(auth['access_token'])}"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
headers = {
|
| 132 |
+
'Referer': Config.REQUIRED_REFERER,
|
| 133 |
+
'User-Agent': 'Mozilla/5.0'
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
async with httpx.AsyncClient(
|
| 137 |
+
timeout=httpx.Timeout(30.0, connect=5.0),
|
| 138 |
+
follow_redirects=True,
|
| 139 |
+
limits=httpx.Limits(max_keepalive_connections=50, max_connections=200)
|
| 140 |
+
) as client:
|
| 141 |
+
main_response = await client.get(source_url, headers=headers)
|
| 142 |
+
|
| 143 |
+
if main_response.status_code in [401, 403] and retry_count < 2:
|
| 144 |
+
new_auth = await get_auth(force=True)
|
| 145 |
+
return await proxy_playback_stream(path, request, retry_count + 1)
|
| 146 |
+
|
| 147 |
+
if not main_response.is_success:
|
| 148 |
+
raise Exception(f"VOD source failed: HTTP {main_response.status_code}")
|
| 149 |
+
|
| 150 |
+
main_content = main_response.text
|
| 151 |
+
playlist_url = extract_playlist_url(main_content, source_url)
|
| 152 |
+
|
| 153 |
+
if not playlist_url:
|
| 154 |
+
playlist_url = source_url
|
| 155 |
+
|
| 156 |
+
cache.set_stream(cache_key, playlist_url)
|
| 157 |
+
|
| 158 |
+
headers = {
|
| 159 |
+
'Referer': Config.REQUIRED_REFERER,
|
| 160 |
+
'User-Agent': 'Mozilla/5.0'
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
async with httpx.AsyncClient(
|
| 164 |
+
timeout=httpx.Timeout(30.0, connect=5.0),
|
| 165 |
+
follow_redirects=True,
|
| 166 |
+
limits=httpx.Limits(max_keepalive_connections=50, max_connections=200)
|
| 167 |
+
) as client:
|
| 168 |
+
playlist_response = await client.get(playlist_url, headers=headers)
|
| 169 |
+
|
| 170 |
+
if not playlist_response.is_success:
|
| 171 |
+
raise Exception(f"VOD playlist failed: HTTP {playlist_response.status_code}")
|
| 172 |
+
|
| 173 |
+
playlist_content = playlist_response.text
|
| 174 |
+
|
| 175 |
+
parsed = urlparse(playlist_url)
|
| 176 |
+
playlist_path = parsed.path + ('?' + parsed.query if parsed.query else '')
|
| 177 |
+
|
| 178 |
+
worker_base = get_client_base_url(request)
|
| 179 |
+
rewritten = rewrite_m3u8(playlist_content, playlist_path, worker_base)
|
| 180 |
+
|
| 181 |
+
return Response(
|
| 182 |
+
content=rewritten,
|
| 183 |
+
media_type='application/vnd.apple.mpegurl',
|
| 184 |
+
headers={
|
| 185 |
+
'Cache-Control': 'public, max-age=60',
|
| 186 |
+
'Access-Control-Allow-Origin': '*',
|
| 187 |
+
'Access-Control-Allow-Headers': 'Range',
|
| 188 |
+
'Access-Control-Expose-Headers': 'Content-Length, Content-Range'
|
| 189 |
+
}
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
except httpx.HTTPStatusError as e:
|
| 193 |
+
if e.response.status_code in [401, 403] and retry_count < 2:
|
| 194 |
+
new_auth = await get_auth(force=True)
|
| 195 |
+
return await proxy_playback_stream(path, request, retry_count + 1)
|
| 196 |
+
|
| 197 |
+
return Response(
|
| 198 |
+
content=f'{{"error": "Upstream error: {str(e)}"}}',
|
| 199 |
+
status_code=502,
|
| 200 |
+
media_type='application/json'
|
| 201 |
+
)
|
| 202 |
+
except Exception as e:
|
| 203 |
+
return Response(
|
| 204 |
+
content=f'{{"error": "{str(e)}"}}',
|
| 205 |
+
status_code=500,
|
| 206 |
+
media_type='application/json'
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
async def proxy_media(request: Request, path: str, retry_count: int = 0) -> Response:
|
| 210 |
+
try:
|
| 211 |
+
auth = await get_auth()
|
| 212 |
+
|
| 213 |
+
if path.startswith('/live/'):
|
| 214 |
+
upstream_host = Config.UPSTREAM_HOSTS['live']
|
| 215 |
+
elif path.startswith('/vod/') or path.startswith('/query/'):
|
| 216 |
+
upstream_host = Config.UPSTREAM_HOSTS['vod']
|
| 217 |
+
else:
|
| 218 |
+
upstream_host = Config.UPSTREAM_HOSTS['live']
|
| 219 |
+
|
| 220 |
+
query = str(request.url.query)
|
| 221 |
+
upstream_url = f"{upstream_host}{path}"
|
| 222 |
+
if query:
|
| 223 |
+
upstream_url += f"?{query}"
|
| 224 |
+
|
| 225 |
+
headers = {
|
| 226 |
+
'Referer': Config.REQUIRED_REFERER,
|
| 227 |
+
'User-Agent': 'Mozilla/5.0'
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
range_header = request.headers.get('Range')
|
| 231 |
+
if range_header:
|
| 232 |
+
headers['Range'] = range_header
|
| 233 |
+
|
| 234 |
+
async with httpx.AsyncClient(
|
| 235 |
+
timeout=httpx.Timeout(30.0, connect=5.0),
|
| 236 |
+
follow_redirects=True,
|
| 237 |
+
limits=httpx.Limits(
|
| 238 |
+
max_keepalive_connections=50,
|
| 239 |
+
max_connections=200,
|
| 240 |
+
keepalive_expiry=30.0
|
| 241 |
+
)
|
| 242 |
+
) as client:
|
| 243 |
+
response = await client.get(upstream_url, headers=headers)
|
| 244 |
+
|
| 245 |
+
if response.status_code in [401, 403] and retry_count < 2:
|
| 246 |
+
new_auth = await get_auth(force=True)
|
| 247 |
+
return await proxy_media(request, path, retry_count + 1)
|
| 248 |
+
|
| 249 |
+
response.raise_for_status()
|
| 250 |
+
|
| 251 |
+
content_type = response.headers.get('Content-Type', '')
|
| 252 |
+
|
| 253 |
+
if 'mpegurl' in content_type or path.endswith(('.m3u8', '.M3U8')):
|
| 254 |
+
content = response.text
|
| 255 |
+
worker_base = get_client_base_url(request)
|
| 256 |
+
|
| 257 |
+
full_path = path
|
| 258 |
+
if query:
|
| 259 |
+
full_path += f"?{query}"
|
| 260 |
+
|
| 261 |
+
rewritten = rewrite_m3u8(content, full_path, worker_base)
|
| 262 |
+
|
| 263 |
+
return Response(
|
| 264 |
+
content=rewritten,
|
| 265 |
+
media_type='application/vnd.apple.mpegurl',
|
| 266 |
+
headers={
|
| 267 |
+
'Cache-Control': 'public, max-age=10',
|
| 268 |
+
'Access-Control-Allow-Origin': '*'
|
| 269 |
+
}
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
response_headers = {
|
| 273 |
+
'Content-Type': content_type or 'video/MP2T',
|
| 274 |
+
'Cache-Control': 'public, max-age=86400',
|
| 275 |
+
'Access-Control-Allow-Origin': '*',
|
| 276 |
+
'Access-Control-Allow-Headers': 'Range',
|
| 277 |
+
'Access-Control-Expose-Headers': 'Content-Length, Content-Range'
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
if 'Content-Length' in response.headers:
|
| 281 |
+
response_headers['Content-Length'] = response.headers['Content-Length']
|
| 282 |
+
if 'Content-Range' in response.headers:
|
| 283 |
+
response_headers['Content-Range'] = response.headers['Content-Range']
|
| 284 |
+
if 'Accept-Ranges' in response.headers:
|
| 285 |
+
response_headers['Accept-Ranges'] = response.headers['Accept-Ranges']
|
| 286 |
+
|
| 287 |
+
return Response(
|
| 288 |
+
content=response.content,
|
| 289 |
+
status_code=response.status_code,
|
| 290 |
+
headers=response_headers
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
except httpx.HTTPStatusError as e:
|
| 294 |
+
if e.response.status_code in [401, 403] and retry_count < 2:
|
| 295 |
+
new_auth = await get_auth(force=True)
|
| 296 |
+
return await proxy_media(request, path, retry_count + 1)
|
| 297 |
+
|
| 298 |
+
return Response(
|
| 299 |
+
content=f'{{"error": "Upstream error: {str(e)}"}}',
|
| 300 |
+
status_code=502,
|
| 301 |
+
media_type='application/json'
|
| 302 |
+
)
|
| 303 |
+
except Exception as e:
|
| 304 |
+
return Response(
|
| 305 |
+
content=f'{{"error": "{str(e)}"}}',
|
| 306 |
+
status_code=500,
|
| 307 |
+
media_type='application/json'
|
| 308 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.109.0
|
| 2 |
+
uvicorn[standard]==0.27.0
|
| 3 |
+
httpx==0.26.0
|
| 4 |
+
aiohttp==3.9.1
|
| 5 |
+
python-dateutil==2.8.2
|
| 6 |
+
python-dotenv==1.0.0
|
| 7 |
+
pydantic==2.5.0
|
| 8 |
+
upstash-redis==0.15.0
|
| 9 |
+
beautifulsoup4==4.12.3
|
| 10 |
+
lxml==5.1.0
|
static/admin-login.html
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>管理员登录 - Media Gateway</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<style>
|
| 10 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
| 11 |
+
|
| 12 |
+
* {
|
| 13 |
+
margin: 0;
|
| 14 |
+
padding: 0;
|
| 15 |
+
box-sizing: border-box;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
body {
|
| 19 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 20 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 21 |
+
background-size: 200% 200%;
|
| 22 |
+
animation: gradientShift 15s ease infinite;
|
| 23 |
+
min-height: 100vh;
|
| 24 |
+
display: flex;
|
| 25 |
+
align-items: center;
|
| 26 |
+
justify-content: center;
|
| 27 |
+
padding: 20px;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
@keyframes gradientShift {
|
| 31 |
+
0% { background-position: 0% 50%; }
|
| 32 |
+
50% { background-position: 100% 50%; }
|
| 33 |
+
100% { background-position: 0% 50%; }
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
@keyframes slideIn {
|
| 37 |
+
from {
|
| 38 |
+
opacity: 0;
|
| 39 |
+
transform: translateY(-30px);
|
| 40 |
+
}
|
| 41 |
+
to {
|
| 42 |
+
opacity: 1;
|
| 43 |
+
transform: translateY(0);
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
@keyframes shake {
|
| 48 |
+
0%, 100% { transform: translateX(0); }
|
| 49 |
+
25% { transform: translateX(-10px); }
|
| 50 |
+
75% { transform: translateX(10px); }
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.login-container {
|
| 54 |
+
background: rgba(255, 255, 255, 0.95);
|
| 55 |
+
backdrop-filter: blur(20px);
|
| 56 |
+
border-radius: 30px;
|
| 57 |
+
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.3);
|
| 58 |
+
overflow: hidden;
|
| 59 |
+
max-width: 480px;
|
| 60 |
+
width: 100%;
|
| 61 |
+
animation: slideIn 0.6s ease;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.login-header {
|
| 65 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 66 |
+
color: white;
|
| 67 |
+
padding: 50px 40px;
|
| 68 |
+
text-align: center;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.login-header h1 {
|
| 72 |
+
font-size: 2.5em;
|
| 73 |
+
margin-bottom: 12px;
|
| 74 |
+
font-weight: 800;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.login-header p {
|
| 78 |
+
font-size: 1.1em;
|
| 79 |
+
opacity: 0.95;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.login-body {
|
| 83 |
+
padding: 40px;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.form-group {
|
| 87 |
+
margin-bottom: 25px;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.form-group label {
|
| 91 |
+
display: block;
|
| 92 |
+
margin-bottom: 10px;
|
| 93 |
+
font-weight: 600;
|
| 94 |
+
color: #1e293b;
|
| 95 |
+
font-size: 0.95em;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.input-wrapper {
|
| 99 |
+
position: relative;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.form-group input {
|
| 103 |
+
width: 100%;
|
| 104 |
+
padding: 16px 20px;
|
| 105 |
+
border: 2px solid #e2e8f0;
|
| 106 |
+
border-radius: 12px;
|
| 107 |
+
font-size: 1em;
|
| 108 |
+
transition: all 0.3s ease;
|
| 109 |
+
background: white;
|
| 110 |
+
font-family: inherit;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.form-group input:focus {
|
| 114 |
+
outline: none;
|
| 115 |
+
border-color: #6366f1;
|
| 116 |
+
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.password-toggle {
|
| 120 |
+
position: absolute;
|
| 121 |
+
right: 16px;
|
| 122 |
+
top: 50%;
|
| 123 |
+
transform: translateY(-50%);
|
| 124 |
+
background: none;
|
| 125 |
+
border: none;
|
| 126 |
+
cursor: pointer;
|
| 127 |
+
color: #64748b;
|
| 128 |
+
font-size: 1.3em;
|
| 129 |
+
padding: 6px;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.password-toggle:hover {
|
| 133 |
+
color: #6366f1;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.btn-login {
|
| 137 |
+
width: 100%;
|
| 138 |
+
padding: 18px;
|
| 139 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 140 |
+
color: white;
|
| 141 |
+
border: none;
|
| 142 |
+
border-radius: 12px;
|
| 143 |
+
font-size: 1.1em;
|
| 144 |
+
font-weight: 700;
|
| 145 |
+
cursor: pointer;
|
| 146 |
+
transition: all 0.3s ease;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.btn-login:hover:not(:disabled) {
|
| 150 |
+
transform: translateY(-3px);
|
| 151 |
+
box-shadow: 0 12px 35px rgba(99, 102, 241, 0.4);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.btn-login:disabled {
|
| 155 |
+
opacity: 0.7;
|
| 156 |
+
cursor: not-allowed;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.error-message {
|
| 160 |
+
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
|
| 161 |
+
color: #991b1b;
|
| 162 |
+
padding: 16px;
|
| 163 |
+
border-radius: 12px;
|
| 164 |
+
margin-bottom: 20px;
|
| 165 |
+
display: none;
|
| 166 |
+
animation: shake 0.5s;
|
| 167 |
+
border: 2px solid #fca5a5;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.error-message.show {
|
| 171 |
+
display: block;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.success-message {
|
| 175 |
+
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
| 176 |
+
color: #065f46;
|
| 177 |
+
padding: 16px;
|
| 178 |
+
border-radius: 12px;
|
| 179 |
+
margin-bottom: 20px;
|
| 180 |
+
display: none;
|
| 181 |
+
border: 2px solid #6ee7b7;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.success-message.show {
|
| 185 |
+
display: block;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.info-box {
|
| 189 |
+
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
| 190 |
+
color: #1e40af;
|
| 191 |
+
padding: 16px;
|
| 192 |
+
border-radius: 12px;
|
| 193 |
+
margin-bottom: 25px;
|
| 194 |
+
font-size: 0.9em;
|
| 195 |
+
line-height: 1.6;
|
| 196 |
+
border: 1px solid #93c5fd;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.info-box strong {
|
| 200 |
+
display: block;
|
| 201 |
+
margin-bottom: 8px;
|
| 202 |
+
font-size: 1.05em;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.login-footer {
|
| 206 |
+
padding: 25px 40px;
|
| 207 |
+
background: rgba(248, 250, 252, 0.8);
|
| 208 |
+
text-align: center;
|
| 209 |
+
color: #64748b;
|
| 210 |
+
font-size: 0.9em;
|
| 211 |
+
border-top: 1px solid #e2e8f0;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.login-footer a {
|
| 215 |
+
color: #6366f1;
|
| 216 |
+
text-decoration: none;
|
| 217 |
+
font-weight: 600;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.login-footer a:hover {
|
| 221 |
+
text-decoration: underline;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
@media (max-width: 480px) {
|
| 225 |
+
.login-container {
|
| 226 |
+
margin: 10px;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.login-header {
|
| 230 |
+
padding: 40px 30px;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.login-header h1 {
|
| 234 |
+
font-size: 2em;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.login-body {
|
| 238 |
+
padding: 30px 25px;
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
</style>
|
| 242 |
+
</head>
|
| 243 |
+
<body>
|
| 244 |
+
<div class="login-container">
|
| 245 |
+
<div class="login-header">
|
| 246 |
+
<h1>🔐 管理员登录</h1>
|
| 247 |
+
<p>Media Gateway Admin Panel</p>
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
<div class="login-body">
|
| 251 |
+
<div id="errorMessage" class="error-message"></div>
|
| 252 |
+
<div id="successMessage" class="success-message"></div>
|
| 253 |
+
|
| 254 |
+
<div class="info-box">
|
| 255 |
+
<strong>💡 管理员功能</strong>
|
| 256 |
+
登录后可创建和管理用户账号,配置系统设置
|
| 257 |
+
</div>
|
| 258 |
+
|
| 259 |
+
<form id="loginForm">
|
| 260 |
+
<div class="form-group">
|
| 261 |
+
<label for="username">👤 管理员账号</label>
|
| 262 |
+
<input
|
| 263 |
+
type="text"
|
| 264 |
+
id="username"
|
| 265 |
+
placeholder="请输入管理员账号"
|
| 266 |
+
required
|
| 267 |
+
autocomplete="username"
|
| 268 |
+
>
|
| 269 |
+
</div>
|
| 270 |
+
|
| 271 |
+
<div class="form-group">
|
| 272 |
+
<label for="password">🔒 管理员密码</label>
|
| 273 |
+
<div class="input-wrapper">
|
| 274 |
+
<input
|
| 275 |
+
type="password"
|
| 276 |
+
id="password"
|
| 277 |
+
placeholder="请输入管理员密码"
|
| 278 |
+
required
|
| 279 |
+
autocomplete="current-password"
|
| 280 |
+
>
|
| 281 |
+
<button type="button" class="password-toggle" id="togglePassword">
|
| 282 |
+
👁️
|
| 283 |
+
</button>
|
| 284 |
+
</div>
|
| 285 |
+
</div>
|
| 286 |
+
|
| 287 |
+
<button type="submit" class="btn-login" id="loginBtn">
|
| 288 |
+
🚀 登录管理后台
|
| 289 |
+
</button>
|
| 290 |
+
</form>
|
| 291 |
+
</div>
|
| 292 |
+
|
| 293 |
+
<div class="login-footer">
|
| 294 |
+
<a href="/">← 返回首页</a>
|
| 295 |
+
</div>
|
| 296 |
+
</div>
|
| 297 |
+
|
| 298 |
+
<script>
|
| 299 |
+
(function() {
|
| 300 |
+
'use strict';
|
| 301 |
+
|
| 302 |
+
const API = window.location.origin;
|
| 303 |
+
|
| 304 |
+
function togglePassword() {
|
| 305 |
+
const passwordInput = document.getElementById('password');
|
| 306 |
+
const toggleBtn = document.getElementById('togglePassword');
|
| 307 |
+
|
| 308 |
+
if (!passwordInput || !toggleBtn) return;
|
| 309 |
+
|
| 310 |
+
if (passwordInput.type === 'password') {
|
| 311 |
+
passwordInput.type = 'text';
|
| 312 |
+
toggleBtn.textContent = '🙈';
|
| 313 |
+
} else {
|
| 314 |
+
passwordInput.type = 'password';
|
| 315 |
+
toggleBtn.textContent = '👁️';
|
| 316 |
+
}
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
function showError(message) {
|
| 320 |
+
const errorEl = document.getElementById('errorMessage');
|
| 321 |
+
const successEl = document.getElementById('successMessage');
|
| 322 |
+
|
| 323 |
+
if (!errorEl) return;
|
| 324 |
+
|
| 325 |
+
successEl.classList.remove('show');
|
| 326 |
+
errorEl.textContent = '❌ ' + message;
|
| 327 |
+
errorEl.classList.add('show');
|
| 328 |
+
|
| 329 |
+
setTimeout(() => {
|
| 330 |
+
errorEl.classList.remove('show');
|
| 331 |
+
}, 5000);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
function showSuccess(message) {
|
| 335 |
+
const successEl = document.getElementById('successMessage');
|
| 336 |
+
const errorEl = document.getElementById('errorMessage');
|
| 337 |
+
|
| 338 |
+
if (!successEl) return;
|
| 339 |
+
|
| 340 |
+
errorEl.classList.remove('show');
|
| 341 |
+
successEl.textContent = '✅ ' + message;
|
| 342 |
+
successEl.classList.add('show');
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
async function sha256(message) {
|
| 346 |
+
try {
|
| 347 |
+
const msgBuffer = new TextEncoder().encode(message);
|
| 348 |
+
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
| 349 |
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
| 350 |
+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
| 351 |
+
} catch (error) {
|
| 352 |
+
throw error;
|
| 353 |
+
}
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
function clearAuth() {
|
| 357 |
+
localStorage.removeItem('admin_token');
|
| 358 |
+
localStorage.removeItem('admin_token_expiry');
|
| 359 |
+
localStorage.removeItem('admin_username');
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
function saveAuth(token, username) {
|
| 363 |
+
const expiry = Date.now() + (24 * 60 * 60 * 1000);
|
| 364 |
+
|
| 365 |
+
localStorage.setItem('admin_token', token);
|
| 366 |
+
localStorage.setItem('admin_token_expiry', expiry.toString());
|
| 367 |
+
localStorage.setItem('admin_username', username);
|
| 368 |
+
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
async function login(username, password) {
|
| 372 |
+
try {
|
| 373 |
+
const passwordHash = await sha256(password);
|
| 374 |
+
|
| 375 |
+
const response = await fetch(`${API}/api/admin/login`, {
|
| 376 |
+
method: 'POST',
|
| 377 |
+
headers: {
|
| 378 |
+
'Content-Type': 'application/json'
|
| 379 |
+
},
|
| 380 |
+
body: JSON.stringify({
|
| 381 |
+
username: username,
|
| 382 |
+
password_hash: passwordHash
|
| 383 |
+
})
|
| 384 |
+
});
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
if (!response.ok) {
|
| 388 |
+
const error = await response.json();
|
| 389 |
+
throw new Error(error.message || '登录失败');
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
const data = await response.json();
|
| 393 |
+
|
| 394 |
+
if (data.success && data.token) {
|
| 395 |
+
saveAuth(data.token, username);
|
| 396 |
+
return true;
|
| 397 |
+
} else {
|
| 398 |
+
throw new Error('登录失败:未返回有效 token');
|
| 399 |
+
}
|
| 400 |
+
} catch (error) {
|
| 401 |
+
throw error;
|
| 402 |
+
}
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
async function checkAuthStatus() {
|
| 406 |
+
const token = localStorage.getItem('admin_token');
|
| 407 |
+
const expiry = localStorage.getItem('admin_token_expiry');
|
| 408 |
+
|
| 409 |
+
if (!token || !expiry) {
|
| 410 |
+
return false;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
if (Date.now() > parseInt(expiry)) {
|
| 414 |
+
clearAuth();
|
| 415 |
+
return false;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
try {
|
| 419 |
+
const response = await fetch(`${API}/api/admin/check`, {
|
| 420 |
+
headers: {
|
| 421 |
+
'Authorization': `Bearer ${token}`
|
| 422 |
+
}
|
| 423 |
+
});
|
| 424 |
+
|
| 425 |
+
if (response.ok) {
|
| 426 |
+
const data = await response.json();
|
| 427 |
+
if (data.authenticated) {
|
| 428 |
+
return true;
|
| 429 |
+
}
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
clearAuth();
|
| 433 |
+
return false;
|
| 434 |
+
} catch (error) {
|
| 435 |
+
clearAuth();
|
| 436 |
+
return false;
|
| 437 |
+
}
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
function initialize() {
|
| 441 |
+
|
| 442 |
+
const togglePasswordBtn = document.getElementById('togglePassword');
|
| 443 |
+
if (togglePasswordBtn) {
|
| 444 |
+
togglePasswordBtn.addEventListener('click', togglePassword);
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
const loginForm = document.getElementById('loginForm');
|
| 448 |
+
if (loginForm) {
|
| 449 |
+
loginForm.addEventListener('submit', async (e) => {
|
| 450 |
+
e.preventDefault();
|
| 451 |
+
|
| 452 |
+
const usernameInput = document.getElementById('username');
|
| 453 |
+
const passwordInput = document.getElementById('password');
|
| 454 |
+
const loginBtn = document.getElementById('loginBtn');
|
| 455 |
+
|
| 456 |
+
if (!usernameInput || !passwordInput || !loginBtn) return;
|
| 457 |
+
|
| 458 |
+
const username = usernameInput.value.trim();
|
| 459 |
+
const password = passwordInput.value;
|
| 460 |
+
|
| 461 |
+
if (!username || !password) {
|
| 462 |
+
showError('请输入用户名和密码');
|
| 463 |
+
return;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
loginBtn.disabled = true;
|
| 467 |
+
loginBtn.textContent = '验证中...';
|
| 468 |
+
|
| 469 |
+
try {
|
| 470 |
+
const success = await login(username, password);
|
| 471 |
+
|
| 472 |
+
if (success) {
|
| 473 |
+
showSuccess('登录成功!正在跳转...');
|
| 474 |
+
|
| 475 |
+
setTimeout(() => {
|
| 476 |
+
window.location.href = '/admin';
|
| 477 |
+
}, 1500);
|
| 478 |
+
} else {
|
| 479 |
+
showError('登录失败,请重试');
|
| 480 |
+
loginBtn.disabled = false;
|
| 481 |
+
loginBtn.textContent = '🚀 登录管理后台';
|
| 482 |
+
passwordInput.value = '';
|
| 483 |
+
passwordInput.focus();
|
| 484 |
+
}
|
| 485 |
+
} catch (error) {
|
| 486 |
+
showError(error.message || '登录失败,请重试');
|
| 487 |
+
loginBtn.disabled = false;
|
| 488 |
+
loginBtn.textContent = '🚀 登录管理后台';
|
| 489 |
+
passwordInput.value = '';
|
| 490 |
+
passwordInput.focus();
|
| 491 |
+
}
|
| 492 |
+
});
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
checkAuthStatus().then(isValid => {
|
| 496 |
+
if (isValid) {
|
| 497 |
+
window.location.href = '/admin';
|
| 498 |
+
} else {
|
| 499 |
+
}
|
| 500 |
+
});
|
| 501 |
+
|
| 502 |
+
setTimeout(() => {
|
| 503 |
+
const usernameInput = document.getElementById('username');
|
| 504 |
+
if (usernameInput) {
|
| 505 |
+
usernameInput.focus();
|
| 506 |
+
}
|
| 507 |
+
}, 300);
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
if (document.readyState === 'loading') {
|
| 511 |
+
document.addEventListener('DOMContentLoaded', initialize);
|
| 512 |
+
} else {
|
| 513 |
+
initialize();
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
})();
|
| 517 |
+
</script>
|
| 518 |
+
</body>
|
| 519 |
+
</html>
|
static/admin.html
ADDED
|
@@ -0,0 +1,1321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>管理员后台 - Media Gateway</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<style>
|
| 10 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
| 11 |
+
|
| 12 |
+
:root {
|
| 13 |
+
--primary: #6366f1;
|
| 14 |
+
--primary-dark: #4f46e5;
|
| 15 |
+
--primary-light: #818cf8;
|
| 16 |
+
--secondary: #ec4899;
|
| 17 |
+
--success: #10b981;
|
| 18 |
+
--warning: #f59e0b;
|
| 19 |
+
--danger: #ef4444;
|
| 20 |
+
--dark: #1e293b;
|
| 21 |
+
--light: #f8fafc;
|
| 22 |
+
--border: #e2e8f0;
|
| 23 |
+
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 24 |
+
--gradient-success: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
| 25 |
+
--gradient-warning: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
| 26 |
+
--gradient-danger: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
| 27 |
+
--shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
|
| 28 |
+
--shadow-lg: 0 20px 60px rgba(0, 0, 0, 0.15);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
* {
|
| 32 |
+
margin: 0;
|
| 33 |
+
padding: 0;
|
| 34 |
+
box-sizing: border-box;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
body {
|
| 38 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 39 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 40 |
+
background-size: 200% 200%;
|
| 41 |
+
animation: gradientShift 15s ease infinite;
|
| 42 |
+
background-attachment: fixed;
|
| 43 |
+
min-height: 100vh;
|
| 44 |
+
padding: 20px;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
@keyframes gradientShift {
|
| 48 |
+
0% { background-position: 0% 50%; }
|
| 49 |
+
50% { background-position: 100% 50%; }
|
| 50 |
+
100% { background-position: 0% 50%; }
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
@keyframes fadeIn {
|
| 54 |
+
from { opacity: 0; transform: translateY(20px); }
|
| 55 |
+
to { opacity: 1; transform: translateY(0); }
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
@keyframes scaleIn {
|
| 59 |
+
from { opacity: 0; transform: scale(0.95); }
|
| 60 |
+
to { opacity: 1; transform: scale(1); }
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
@keyframes spin {
|
| 64 |
+
to { transform: rotate(360deg); }
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.loading-overlay {
|
| 68 |
+
position: fixed;
|
| 69 |
+
top: 0;
|
| 70 |
+
left: 0;
|
| 71 |
+
width: 100%;
|
| 72 |
+
height: 100%;
|
| 73 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 74 |
+
display: flex;
|
| 75 |
+
flex-direction: column;
|
| 76 |
+
align-items: center;
|
| 77 |
+
justify-content: center;
|
| 78 |
+
z-index: 99999;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.loading-overlay.hide {
|
| 82 |
+
display: none !important;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.loading-spinner-large {
|
| 86 |
+
width: 60px;
|
| 87 |
+
height: 60px;
|
| 88 |
+
border: 5px solid rgba(255,255,255,.2);
|
| 89 |
+
border-radius: 50%;
|
| 90 |
+
border-top-color: #fff;
|
| 91 |
+
animation: spin 1s linear infinite;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.loading-text {
|
| 95 |
+
color: white;
|
| 96 |
+
margin-top: 20px;
|
| 97 |
+
font-size: 1.2em;
|
| 98 |
+
font-weight: 600;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.container {
|
| 102 |
+
max-width: 1600px;
|
| 103 |
+
margin: 0 auto;
|
| 104 |
+
background: rgba(255, 255, 255, 0.95);
|
| 105 |
+
backdrop-filter: blur(20px);
|
| 106 |
+
border-radius: 30px;
|
| 107 |
+
box-shadow: var(--shadow-lg);
|
| 108 |
+
overflow: hidden;
|
| 109 |
+
display: none;
|
| 110 |
+
animation: scaleIn 0.6s ease;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.container.show {
|
| 114 |
+
display: block !important;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.header {
|
| 118 |
+
background: var(--gradient-primary);
|
| 119 |
+
color: white;
|
| 120 |
+
padding: 30px 40px;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.header-top {
|
| 124 |
+
display: flex;
|
| 125 |
+
justify-content: space-between;
|
| 126 |
+
align-items: center;
|
| 127 |
+
margin-bottom: 25px;
|
| 128 |
+
flex-wrap: wrap;
|
| 129 |
+
gap: 15px;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.header h1 {
|
| 133 |
+
font-size: 2.5em;
|
| 134 |
+
margin-bottom: 5px;
|
| 135 |
+
font-weight: 800;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.logout-btn {
|
| 139 |
+
padding: 12px 24px;
|
| 140 |
+
background: rgba(255, 255, 255, 0.2);
|
| 141 |
+
color: white;
|
| 142 |
+
border: 2px solid rgba(255, 255, 255, 0.5);
|
| 143 |
+
border-radius: 50px;
|
| 144 |
+
cursor: pointer;
|
| 145 |
+
font-size: 1em;
|
| 146 |
+
font-weight: 600;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.logout-btn:hover {
|
| 150 |
+
background: rgba(255, 255, 255, 0.3);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.stats-bar {
|
| 154 |
+
display: grid;
|
| 155 |
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
| 156 |
+
gap: 20px;
|
| 157 |
+
padding: 30px 40px;
|
| 158 |
+
background: rgba(248, 250, 252, 0.8);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.stat-card {
|
| 162 |
+
background: white;
|
| 163 |
+
padding: 28px;
|
| 164 |
+
border-radius: 20px;
|
| 165 |
+
box-shadow: var(--shadow);
|
| 166 |
+
text-align: center;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.stat-card h3 {
|
| 170 |
+
color: #64748b;
|
| 171 |
+
font-size: 0.85em;
|
| 172 |
+
margin-bottom: 12px;
|
| 173 |
+
text-transform: uppercase;
|
| 174 |
+
letter-spacing: 1px;
|
| 175 |
+
font-weight: 700;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.stat-card .number {
|
| 179 |
+
font-size: 2.5em;
|
| 180 |
+
font-weight: 800;
|
| 181 |
+
background: var(--gradient-primary);
|
| 182 |
+
-webkit-background-clip: text;
|
| 183 |
+
-webkit-text-fill-color: transparent;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.content {
|
| 187 |
+
padding: 40px;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.section {
|
| 191 |
+
margin-bottom: 50px;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.section h2 {
|
| 195 |
+
margin-bottom: 25px;
|
| 196 |
+
color: var(--dark);
|
| 197 |
+
font-size: 1.8em;
|
| 198 |
+
font-weight: 700;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.form-grid {
|
| 202 |
+
display: grid;
|
| 203 |
+
grid-template-columns: 1fr 1fr;
|
| 204 |
+
gap: 20px;
|
| 205 |
+
margin-bottom: 25px;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.form-group {
|
| 209 |
+
margin-bottom: 20px;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.form-group label {
|
| 213 |
+
display: block;
|
| 214 |
+
margin-bottom: 8px;
|
| 215 |
+
font-weight: 600;
|
| 216 |
+
color: var(--dark);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.form-group input,
|
| 220 |
+
.form-group select {
|
| 221 |
+
width: 100%;
|
| 222 |
+
padding: 14px 18px;
|
| 223 |
+
border: 2px solid var(--border);
|
| 224 |
+
border-radius: 12px;
|
| 225 |
+
font-size: 0.95em;
|
| 226 |
+
font-family: inherit;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.form-group input:focus,
|
| 230 |
+
.form-group select:focus {
|
| 231 |
+
outline: none;
|
| 232 |
+
border-color: var(--primary);
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.btn {
|
| 236 |
+
padding: 12px 24px;
|
| 237 |
+
border: none;
|
| 238 |
+
border-radius: 12px;
|
| 239 |
+
cursor: pointer;
|
| 240 |
+
font-size: 0.95em;
|
| 241 |
+
font-weight: 600;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.btn:hover:not(:disabled) {
|
| 245 |
+
opacity: 0.9;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
.btn:disabled {
|
| 249 |
+
opacity: 0.6;
|
| 250 |
+
cursor: not-allowed;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.btn-primary {
|
| 254 |
+
background: var(--gradient-primary);
|
| 255 |
+
color: white;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.btn-success {
|
| 259 |
+
background: var(--gradient-success);
|
| 260 |
+
color: white;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.btn-warning {
|
| 264 |
+
background: var(--gradient-warning);
|
| 265 |
+
color: white;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.btn-danger {
|
| 269 |
+
background: var(--gradient-danger);
|
| 270 |
+
color: white;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
.user-table {
|
| 274 |
+
width: 100%;
|
| 275 |
+
border-collapse: collapse;
|
| 276 |
+
margin-top: 20px;
|
| 277 |
+
background: white;
|
| 278 |
+
border-radius: 16px;
|
| 279 |
+
overflow: hidden;
|
| 280 |
+
box-shadow: var(--shadow);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.user-table th,
|
| 284 |
+
.user-table td {
|
| 285 |
+
padding: 16px;
|
| 286 |
+
text-align: left;
|
| 287 |
+
border-bottom: 1px solid var(--border);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.user-table th {
|
| 291 |
+
background: var(--gradient-primary);
|
| 292 |
+
color: white;
|
| 293 |
+
font-weight: 700;
|
| 294 |
+
text-transform: uppercase;
|
| 295 |
+
font-size: 0.8em;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.user-table tr:hover {
|
| 299 |
+
background: rgba(99, 102, 241, 0.05);
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
.badge {
|
| 303 |
+
padding: 6px 14px;
|
| 304 |
+
border-radius: 20px;
|
| 305 |
+
font-size: 0.8em;
|
| 306 |
+
font-weight: 700;
|
| 307 |
+
display: inline-block;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.badge-success {
|
| 311 |
+
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
| 312 |
+
color: #065f46;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.badge-danger {
|
| 316 |
+
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
|
| 317 |
+
color: #991b1b;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.badge-warning {
|
| 321 |
+
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
| 322 |
+
color: #92400e;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
.badge-admin {
|
| 326 |
+
background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
|
| 327 |
+
color: white;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
.action-buttons {
|
| 331 |
+
display: flex;
|
| 332 |
+
gap: 8px;
|
| 333 |
+
flex-wrap: wrap;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.action-buttons .btn {
|
| 337 |
+
padding: 8px 14px;
|
| 338 |
+
font-size: 0.85em;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
.user-badge {
|
| 342 |
+
display: inline-flex;
|
| 343 |
+
align-items: center;
|
| 344 |
+
gap: 5px;
|
| 345 |
+
padding: 4px 10px;
|
| 346 |
+
border-radius: 12px;
|
| 347 |
+
font-size: 0.8em;
|
| 348 |
+
font-weight: 700;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.badge-selector {
|
| 352 |
+
display: grid;
|
| 353 |
+
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
| 354 |
+
gap: 12px;
|
| 355 |
+
margin-top: 15px;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.badge-option {
|
| 359 |
+
padding: 12px;
|
| 360 |
+
border: 2px solid var(--border);
|
| 361 |
+
border-radius: 12px;
|
| 362 |
+
cursor: pointer;
|
| 363 |
+
text-align: center;
|
| 364 |
+
transition: all 0.3s ease;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
.badge-option:hover {
|
| 368 |
+
transform: translateY(-3px);
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
.badge-option.selected {
|
| 372 |
+
border-color: var(--primary);
|
| 373 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.badge-preview {
|
| 377 |
+
display: inline-flex;
|
| 378 |
+
align-items: center;
|
| 379 |
+
gap: 5px;
|
| 380 |
+
padding: 6px 12px;
|
| 381 |
+
border-radius: 12px;
|
| 382 |
+
font-size: 0.85em;
|
| 383 |
+
font-weight: 700;
|
| 384 |
+
margin-top: 8px;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.modal {
|
| 388 |
+
display: none;
|
| 389 |
+
position: fixed;
|
| 390 |
+
top: 0;
|
| 391 |
+
left: 0;
|
| 392 |
+
width: 100%;
|
| 393 |
+
height: 100%;
|
| 394 |
+
background: rgba(30, 41, 59, 0.8);
|
| 395 |
+
z-index: 9999;
|
| 396 |
+
align-items: center;
|
| 397 |
+
justify-content: center;
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
.modal.show {
|
| 401 |
+
display: flex;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.modal-content {
|
| 405 |
+
background: white;
|
| 406 |
+
padding: 35px;
|
| 407 |
+
border-radius: 24px;
|
| 408 |
+
max-width: 500px;
|
| 409 |
+
width: 90%;
|
| 410 |
+
max-height: 85vh;
|
| 411 |
+
overflow-y: auto;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.modal-header h2 {
|
| 415 |
+
color: var(--dark);
|
| 416 |
+
font-size: 1.8em;
|
| 417 |
+
font-weight: 700;
|
| 418 |
+
margin-bottom: 24px;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
.modal-footer {
|
| 422 |
+
margin-top: 24px;
|
| 423 |
+
display: flex;
|
| 424 |
+
gap: 12px;
|
| 425 |
+
justify-content: flex-end;
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
.password-display {
|
| 429 |
+
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
| 430 |
+
padding: 24px;
|
| 431 |
+
border-radius: 16px;
|
| 432 |
+
border: 2px dashed #3b82f6;
|
| 433 |
+
margin: 20px 0;
|
| 434 |
+
text-align: center;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
.password-display .password {
|
| 438 |
+
font-size: 1.6em;
|
| 439 |
+
font-weight: 800;
|
| 440 |
+
color: #1e40af;
|
| 441 |
+
font-family: 'Courier New', monospace;
|
| 442 |
+
margin: 16px 0;
|
| 443 |
+
padding: 16px;
|
| 444 |
+
background: white;
|
| 445 |
+
border-radius: 12px;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
.notification {
|
| 449 |
+
position: fixed;
|
| 450 |
+
top: 30px;
|
| 451 |
+
right: 30px;
|
| 452 |
+
padding: 18px 28px;
|
| 453 |
+
border-radius: 16px;
|
| 454 |
+
z-index: 10000;
|
| 455 |
+
color: white;
|
| 456 |
+
font-weight: 600;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
.notification.success {
|
| 460 |
+
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
.notification.error {
|
| 464 |
+
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
@media (max-width: 768px) {
|
| 468 |
+
.form-grid {
|
| 469 |
+
grid-template-columns: 1fr;
|
| 470 |
+
}
|
| 471 |
+
}
|
| 472 |
+
</style>
|
| 473 |
+
</head>
|
| 474 |
+
<body>
|
| 475 |
+
<div class="loading-overlay" id="loadingOverlay">
|
| 476 |
+
<div class="loading-spinner-large"></div>
|
| 477 |
+
<div class="loading-text">验证身份中...</div>
|
| 478 |
+
</div>
|
| 479 |
+
|
| 480 |
+
<div class="container" id="mainContainer">
|
| 481 |
+
<header class="header">
|
| 482 |
+
<div class="header-top">
|
| 483 |
+
<div>
|
| 484 |
+
<h1>🔐 管理员后台</h1>
|
| 485 |
+
<p>Media Gateway - User Management System</p>
|
| 486 |
+
</div>
|
| 487 |
+
<button class="logout-btn" id="logoutBtn">🚪 退出登录</button>
|
| 488 |
+
</div>
|
| 489 |
+
</header>
|
| 490 |
+
|
| 491 |
+
<div class="stats-bar">
|
| 492 |
+
<div class="stat-card">
|
| 493 |
+
<h3>总用户数</h3>
|
| 494 |
+
<div class="number" id="totalUsers">0</div>
|
| 495 |
+
</div>
|
| 496 |
+
<div class="stat-card">
|
| 497 |
+
<h3>活跃用户</h3>
|
| 498 |
+
<div class="number" id="activeUsers">0</div>
|
| 499 |
+
</div>
|
| 500 |
+
<div class="stat-card">
|
| 501 |
+
<h3>已过期</h3>
|
| 502 |
+
<div class="number" id="expiredUsers">0</div>
|
| 503 |
+
</div>
|
| 504 |
+
<div class="stat-card">
|
| 505 |
+
<h3>已禁用</h3>
|
| 506 |
+
<div class="number" id="inactiveUsers">0</div>
|
| 507 |
+
</div>
|
| 508 |
+
</div>
|
| 509 |
+
|
| 510 |
+
<div class="content">
|
| 511 |
+
<div class="section">
|
| 512 |
+
<h2>➕ 创建新用户</h2>
|
| 513 |
+
<form id="createUserForm">
|
| 514 |
+
<div class="form-grid">
|
| 515 |
+
<div class="form-group">
|
| 516 |
+
<label for="username">👤 用户名 *</label>
|
| 517 |
+
<input type="text" id="username" placeholder="请输入用户名" required>
|
| 518 |
+
</div>
|
| 519 |
+
<div class="form-group">
|
| 520 |
+
<label for="password">🔒 密码(留空自动生成)</label>
|
| 521 |
+
<input type="text" id="password" placeholder="留空则自动生成强密码">
|
| 522 |
+
</div>
|
| 523 |
+
<div class="form-group">
|
| 524 |
+
<label for="expiryDays">⏰ 有效期(天)</label>
|
| 525 |
+
<select id="expiryDays">
|
| 526 |
+
<option value="">永久有效</option>
|
| 527 |
+
<option value="7">7天</option>
|
| 528 |
+
<option value="30" selected>30天</option>
|
| 529 |
+
<option value="90">90天</option>
|
| 530 |
+
<option value="180">180天</option>
|
| 531 |
+
<option value="365">365天</option>
|
| 532 |
+
<option value="custom">自定义天数</option>
|
| 533 |
+
</select>
|
| 534 |
+
</div>
|
| 535 |
+
<div class="form-group" id="customDaysGroup" style="display: none;">
|
| 536 |
+
<label for="customDays">🔢 自定义天数</label>
|
| 537 |
+
<input type="number" id="customDays" placeholder="输入天数" min="1">
|
| 538 |
+
</div>
|
| 539 |
+
<!-- ✅ 用户类型选择 -->
|
| 540 |
+
<div class="form-group">
|
| 541 |
+
<label for="userType">👑 用户类型</label>
|
| 542 |
+
<select id="userType">
|
| 543 |
+
<option value="user" selected>👤 普通用户</option>
|
| 544 |
+
<option value="admin">👑 管理员</option>
|
| 545 |
+
</select>
|
| 546 |
+
</div>
|
| 547 |
+
<div class="form-group">
|
| 548 |
+
<label for="notes">📝 备注信息</label>
|
| 549 |
+
<input type="text" id="notes" placeholder="可选,添加备注信息">
|
| 550 |
+
</div>
|
| 551 |
+
</div>
|
| 552 |
+
|
| 553 |
+
<div class="form-group">
|
| 554 |
+
<label>🏷️ 用户徽章(可选)</label>
|
| 555 |
+
<div class="badge-selector" id="badgeSelector">
|
| 556 |
+
<div class="badge-option selected" data-badge="" onclick="selectBadge('')">
|
| 557 |
+
<div style="font-size: 2em;">❌</div>
|
| 558 |
+
<div style="margin-top: 5px; font-size: 0.85em;">无徽章</div>
|
| 559 |
+
</div>
|
| 560 |
+
</div>
|
| 561 |
+
<input type="hidden" id="selectedBadge" value="">
|
| 562 |
+
</div>
|
| 563 |
+
|
| 564 |
+
<button type="submit" class="btn btn-primary" style="width: 100%; padding: 16px;">
|
| 565 |
+
➕ 创建用户
|
| 566 |
+
</button>
|
| 567 |
+
</form>
|
| 568 |
+
</div>
|
| 569 |
+
|
| 570 |
+
<div class="section">
|
| 571 |
+
<h2>👥 用户列表</h2>
|
| 572 |
+
<button class="btn btn-success" onclick="loadUsers()" style="margin-bottom: 20px;">
|
| 573 |
+
🔄 刷新列表
|
| 574 |
+
</button>
|
| 575 |
+
<div style="overflow-x: auto;">
|
| 576 |
+
<table class="user-table">
|
| 577 |
+
<thead>
|
| 578 |
+
<tr>
|
| 579 |
+
<th>用户名</th>
|
| 580 |
+
<th>类型</th>
|
| 581 |
+
<th>徽章</th>
|
| 582 |
+
<th>创建时间</th>
|
| 583 |
+
<th>过期时间</th>
|
| 584 |
+
<th>最后登录</th>
|
| 585 |
+
<th>状态</th>
|
| 586 |
+
<th>备注</th>
|
| 587 |
+
<th>操作</th>
|
| 588 |
+
</tr>
|
| 589 |
+
</thead>
|
| 590 |
+
<tbody id="userTableBody">
|
| 591 |
+
<tr>
|
| 592 |
+
<td colspan="9" style="text-align: center; padding: 40px;">
|
| 593 |
+
<div class="loading-spinner-large" style="margin: 0 auto 15px;"></div>
|
| 594 |
+
<p>加载用户列表中...</p>
|
| 595 |
+
</td>
|
| 596 |
+
</tr>
|
| 597 |
+
</tbody>
|
| 598 |
+
</table>
|
| 599 |
+
</div>
|
| 600 |
+
</div>
|
| 601 |
+
</div>
|
| 602 |
+
</div>
|
| 603 |
+
|
| 604 |
+
<div class="modal" id="passwordModal">
|
| 605 |
+
<div class="modal-content">
|
| 606 |
+
<div class="modal-header">
|
| 607 |
+
<h2>✅ 用户创建成功</h2>
|
| 608 |
+
</div>
|
| 609 |
+
<div class="password-display">
|
| 610 |
+
<p><strong>用户名:</strong><span id="displayUsername"></span></p>
|
| 611 |
+
<p><strong>登录密码:</strong></p>
|
| 612 |
+
<div class="password" id="displayPassword"></div>
|
| 613 |
+
<button class="btn btn-primary" onclick="copyPassword()">
|
| 614 |
+
📋 复制密码
|
| 615 |
+
</button>
|
| 616 |
+
</div>
|
| 617 |
+
<p style="color: var(--danger); font-weight: 700; margin-top: 16px;">
|
| 618 |
+
⚠️ 请务必保��此密码!关闭后将无法再次查看。
|
| 619 |
+
</p>
|
| 620 |
+
<div class="modal-footer">
|
| 621 |
+
<button class="btn btn-primary" onclick="closePasswordModal()">
|
| 622 |
+
我已保存密码
|
| 623 |
+
</button>
|
| 624 |
+
</div>
|
| 625 |
+
</div>
|
| 626 |
+
</div>
|
| 627 |
+
|
| 628 |
+
<div class="modal" id="badgeModal">
|
| 629 |
+
<div class="modal-content">
|
| 630 |
+
<div class="modal-header">
|
| 631 |
+
<h2>🏷️ 设置用户徽章</h2>
|
| 632 |
+
</div>
|
| 633 |
+
<p style="margin-bottom: 20px; color: #64748b;">
|
| 634 |
+
为用户 <strong id="badgeUsername"></strong> 选择徽章
|
| 635 |
+
</p>
|
| 636 |
+
<div class="badge-selector" id="badgeSelectorModal"></div>
|
| 637 |
+
<div class="modal-footer">
|
| 638 |
+
<button class="btn btn-primary" onclick="confirmBadge()">
|
| 639 |
+
✅ 确认设置
|
| 640 |
+
</button>
|
| 641 |
+
<button class="btn" onclick="closeBadgeModal()" style="background: #94a3b8; color: white;">
|
| 642 |
+
取消
|
| 643 |
+
</button>
|
| 644 |
+
</div>
|
| 645 |
+
</div>
|
| 646 |
+
</div>
|
| 647 |
+
|
| 648 |
+
<script>
|
| 649 |
+
(function() {
|
| 650 |
+
'use strict';
|
| 651 |
+
|
| 652 |
+
const API = window.location.origin;
|
| 653 |
+
let availableBadges = {};
|
| 654 |
+
let currentBadgeUsername = '';
|
| 655 |
+
let selectedModalBadge = '';
|
| 656 |
+
|
| 657 |
+
function getAdminToken() {
|
| 658 |
+
const token = localStorage.getItem('admin_token');
|
| 659 |
+
return token;
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
function getTokenExpiry() {
|
| 663 |
+
const expiry = localStorage.getItem('admin_token_expiry');
|
| 664 |
+
return expiry ? parseInt(expiry) : 0;
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
function clearAuth() {
|
| 668 |
+
localStorage.removeItem('admin_token');
|
| 669 |
+
localStorage.removeItem('admin_token_expiry');
|
| 670 |
+
localStorage.removeItem('admin_username');
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
async function checkAuth() {
|
| 674 |
+
const token = getAdminToken();
|
| 675 |
+
const expiry = getTokenExpiry();
|
| 676 |
+
|
| 677 |
+
if (!token) {
|
| 678 |
+
return false;
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
if (Date.now() > expiry) {
|
| 682 |
+
clearAuth();
|
| 683 |
+
return false;
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
try {
|
| 687 |
+
const response = await fetch(`${API}/api/admin/check`, {
|
| 688 |
+
method: 'GET',
|
| 689 |
+
headers: {
|
| 690 |
+
'Authorization': `Bearer ${token}`
|
| 691 |
+
}
|
| 692 |
+
});
|
| 693 |
+
|
| 694 |
+
if (!response.ok) {
|
| 695 |
+
clearAuth();
|
| 696 |
+
return false;
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
const data = await response.json();
|
| 700 |
+
|
| 701 |
+
if (data.authenticated === true) {
|
| 702 |
+
return true;
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
clearAuth();
|
| 706 |
+
return false;
|
| 707 |
+
|
| 708 |
+
} catch (error) {
|
| 709 |
+
clearAuth();
|
| 710 |
+
return false;
|
| 711 |
+
}
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
function redirectToLogin() {
|
| 715 |
+
setTimeout(() => {
|
| 716 |
+
window.location.href = '/admin/login';
|
| 717 |
+
}, 1000);
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
function logout() {
|
| 721 |
+
if (confirm('确定要退出登录吗?')) {
|
| 722 |
+
clearAuth();
|
| 723 |
+
redirectToLogin();
|
| 724 |
+
}
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
function showNotification(message, type = 'success') {
|
| 728 |
+
const notification = document.createElement('div');
|
| 729 |
+
notification.className = `notification ${type}`;
|
| 730 |
+
notification.textContent = message;
|
| 731 |
+
document.body.appendChild(notification);
|
| 732 |
+
|
| 733 |
+
setTimeout(() => {
|
| 734 |
+
notification.remove();
|
| 735 |
+
}, 3000);
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
function showLoading(text = '验证身份中...') {
|
| 739 |
+
const overlay = document.getElementById('loadingOverlay');
|
| 740 |
+
const loadingText = overlay?.querySelector('.loading-text');
|
| 741 |
+
if (overlay) {
|
| 742 |
+
overlay.classList.remove('hide');
|
| 743 |
+
if (loadingText) loadingText.textContent = text;
|
| 744 |
+
}
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
function hideLoading() {
|
| 748 |
+
const overlay = document.getElementById('loadingOverlay');
|
| 749 |
+
if (overlay) overlay.classList.add('hide');
|
| 750 |
+
|
| 751 |
+
const container = document.getElementById('mainContainer');
|
| 752 |
+
if (container) container.classList.add('show');
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
async function loadBadges() {
|
| 756 |
+
try {
|
| 757 |
+
const token = getAdminToken();
|
| 758 |
+
const response = await fetch(`${API}/api/admin/badges`, {
|
| 759 |
+
headers: { 'Authorization': `Bearer ${token}` }
|
| 760 |
+
});
|
| 761 |
+
|
| 762 |
+
if (response.ok) {
|
| 763 |
+
const data = await response.json();
|
| 764 |
+
availableBadges = data.badges || {};
|
| 765 |
+
renderBadgeSelector();
|
| 766 |
+
}
|
| 767 |
+
} catch (error) {
|
| 768 |
+
}
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
function renderBadgeSelector() {
|
| 772 |
+
const selector = document.getElementById('badgeSelector');
|
| 773 |
+
if (!selector) return;
|
| 774 |
+
|
| 775 |
+
let html = `
|
| 776 |
+
<div class="badge-option selected" data-badge="" onclick="selectBadge('')">
|
| 777 |
+
<div style="font-size: 2em;">❌</div>
|
| 778 |
+
<div style="margin-top: 5px; font-size: 0.85em;">无徽章</div>
|
| 779 |
+
</div>
|
| 780 |
+
`;
|
| 781 |
+
|
| 782 |
+
for (const [id, badge] of Object.entries(availableBadges)) {
|
| 783 |
+
html += `
|
| 784 |
+
<div class="badge-option" data-badge="${id}" onclick="selectBadge('${id}')">
|
| 785 |
+
<div style="font-size: 2em;">${badge.icon}</div>
|
| 786 |
+
<div class="badge-preview" style="
|
| 787 |
+
background: ${badge.gradient};
|
| 788 |
+
color: ${badge.color};
|
| 789 |
+
border: 2px solid ${badge.border};
|
| 790 |
+
box-shadow: 0 2px 8px ${badge.glow};
|
| 791 |
+
">
|
| 792 |
+
${badge.name}
|
| 793 |
+
</div>
|
| 794 |
+
</div>
|
| 795 |
+
`;
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
selector.innerHTML = html;
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
window.selectBadge = function(badgeId) {
|
| 802 |
+
const options = document.querySelectorAll('#badgeSelector .badge-option');
|
| 803 |
+
options.forEach(opt => {
|
| 804 |
+
opt.classList.toggle('selected', opt.dataset.badge === badgeId);
|
| 805 |
+
});
|
| 806 |
+
document.getElementById('selectedBadge').value = badgeId;
|
| 807 |
+
};
|
| 808 |
+
|
| 809 |
+
window.openBadgeModal = function(username) {
|
| 810 |
+
currentBadgeUsername = username;
|
| 811 |
+
document.getElementById('badgeUsername').textContent = username;
|
| 812 |
+
|
| 813 |
+
const modalSelector = document.getElementById('badgeSelectorModal');
|
| 814 |
+
if (!modalSelector) return;
|
| 815 |
+
|
| 816 |
+
let html = `
|
| 817 |
+
<div class="badge-option selected" data-badge="" onclick="selectModalBadge('')">
|
| 818 |
+
<div style="font-size: 2em;">❌</div>
|
| 819 |
+
<div style="margin-top: 5px; font-size: 0.85em;">无徽章</div>
|
| 820 |
+
</div>
|
| 821 |
+
`;
|
| 822 |
+
|
| 823 |
+
for (const [id, badge] of Object.entries(availableBadges)) {
|
| 824 |
+
html += `
|
| 825 |
+
<div class="badge-option" data-badge="${id}" onclick="selectModalBadge('${id}')">
|
| 826 |
+
<div style="font-size: 2em;">${badge.icon}</div>
|
| 827 |
+
<div class="badge-preview" style="
|
| 828 |
+
background: ${badge.gradient};
|
| 829 |
+
color: ${badge.color};
|
| 830 |
+
border: 2px solid ${badge.border};
|
| 831 |
+
box-shadow: 0 2px 8px ${badge.glow};
|
| 832 |
+
">
|
| 833 |
+
${badge.name}
|
| 834 |
+
</div>
|
| 835 |
+
</div>
|
| 836 |
+
`;
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
modalSelector.innerHTML = html;
|
| 840 |
+
selectedModalBadge = '';
|
| 841 |
+
document.getElementById('badgeModal').classList.add('show');
|
| 842 |
+
};
|
| 843 |
+
|
| 844 |
+
window.selectModalBadge = function(badgeId) {
|
| 845 |
+
const options = document.querySelectorAll('#badgeSelectorModal .badge-option');
|
| 846 |
+
options.forEach(opt => {
|
| 847 |
+
opt.classList.toggle('selected', opt.dataset.badge === badgeId);
|
| 848 |
+
});
|
| 849 |
+
selectedModalBadge = badgeId;
|
| 850 |
+
};
|
| 851 |
+
|
| 852 |
+
window.confirmBadge = async function() {
|
| 853 |
+
if (!currentBadgeUsername) {
|
| 854 |
+
showNotification('⚠️ 未选择用户', 'error');
|
| 855 |
+
return;
|
| 856 |
+
}
|
| 857 |
+
|
| 858 |
+
const token = getAdminToken();
|
| 859 |
+
|
| 860 |
+
if (!token) {
|
| 861 |
+
showNotification('⚠️ 未登录,请重新登录', 'error');
|
| 862 |
+
setTimeout(() => {
|
| 863 |
+
window.location.href = '/admin/login';
|
| 864 |
+
}, 1500);
|
| 865 |
+
return;
|
| 866 |
+
}
|
| 867 |
+
|
| 868 |
+
try {
|
| 869 |
+
const response = await fetch(`${API}/api/admin/users/${currentBadgeUsername}/badge`, {
|
| 870 |
+
method: 'POST',
|
| 871 |
+
headers: {
|
| 872 |
+
'Authorization': `Bearer ${token}`,
|
| 873 |
+
'Content-Type': 'application/json'
|
| 874 |
+
},
|
| 875 |
+
body: JSON.stringify({
|
| 876 |
+
badge: selectedModalBadge || null
|
| 877 |
+
})
|
| 878 |
+
});
|
| 879 |
+
|
| 880 |
+
if (response.status === 401) {
|
| 881 |
+
clearAuth();
|
| 882 |
+
showNotification('❌ 登录已过期,请重新登录', 'error');
|
| 883 |
+
setTimeout(() => {
|
| 884 |
+
window.location.href = '/admin/login';
|
| 885 |
+
}, 1500);
|
| 886 |
+
return;
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
if (!response.ok) {
|
| 890 |
+
const errorData = await response.json();
|
| 891 |
+
throw new Error(errorData.error || '设置失败');
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
const data = await response.json();
|
| 895 |
+
|
| 896 |
+
showNotification('✅ 徽章设置成功', 'success');
|
| 897 |
+
closeBadgeModal();
|
| 898 |
+
await loadUsers();
|
| 899 |
+
} catch (error) {
|
| 900 |
+
showNotification('❌ 设置徽章失败: ' + error.message, 'error');
|
| 901 |
+
}
|
| 902 |
+
};
|
| 903 |
+
|
| 904 |
+
window.closeBadgeModal = function() {
|
| 905 |
+
document.getElementById('badgeModal').classList.remove('show');
|
| 906 |
+
currentBadgeUsername = '';
|
| 907 |
+
selectedModalBadge = '';
|
| 908 |
+
};
|
| 909 |
+
|
| 910 |
+
async function loadStats() {
|
| 911 |
+
const token = getAdminToken();
|
| 912 |
+
|
| 913 |
+
try {
|
| 914 |
+
const response = await fetch(`${API}/api/admin/stats`, {
|
| 915 |
+
headers: { 'Authorization': `Bearer ${token}` }
|
| 916 |
+
});
|
| 917 |
+
|
| 918 |
+
if (response.ok) {
|
| 919 |
+
const data = await response.json();
|
| 920 |
+
document.getElementById('totalUsers').textContent = data.total || 0;
|
| 921 |
+
document.getElementById('activeUsers').textContent = data.active || 0;
|
| 922 |
+
document.getElementById('expiredUsers').textContent = data.expired || 0;
|
| 923 |
+
document.getElementById('inactiveUsers').textContent = data.inactive || 0;
|
| 924 |
+
}
|
| 925 |
+
} catch (error) {
|
| 926 |
+
}
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
async function loadUsers() {
|
| 930 |
+
const token = getAdminToken();
|
| 931 |
+
const tbody = document.getElementById('userTableBody');
|
| 932 |
+
|
| 933 |
+
tbody.innerHTML = `
|
| 934 |
+
<tr>
|
| 935 |
+
<td colspan="9" style="text-align: center; padding: 40px;">
|
| 936 |
+
<div class="loading-spinner-large" style="margin: 0 auto 15px;"></div>
|
| 937 |
+
<p>加载中...</p>
|
| 938 |
+
</td>
|
| 939 |
+
</tr>
|
| 940 |
+
`;
|
| 941 |
+
|
| 942 |
+
try {
|
| 943 |
+
const response = await fetch(`${API}/api/admin/users`, {
|
| 944 |
+
headers: { 'Authorization': `Bearer ${token}` }
|
| 945 |
+
});
|
| 946 |
+
|
| 947 |
+
if (!response.ok) {
|
| 948 |
+
throw new Error(`HTTP ${response.status}`);
|
| 949 |
+
}
|
| 950 |
+
|
| 951 |
+
const data = await response.json();
|
| 952 |
+
|
| 953 |
+
renderUsers(data.users);
|
| 954 |
+
await loadStats();
|
| 955 |
+
showNotification('✅ 用户列表已刷新', 'success');
|
| 956 |
+
} catch (error) {
|
| 957 |
+
tbody.innerHTML = `
|
| 958 |
+
<tr>
|
| 959 |
+
<td colspan="9" style="text-align: center; padding: 40px; color: #ef4444;">
|
| 960 |
+
❌ 加载失败: ${error.message}
|
| 961 |
+
</td>
|
| 962 |
+
</tr>
|
| 963 |
+
`;
|
| 964 |
+
showNotification('❌ 加载用户列表失败', 'error');
|
| 965 |
+
}
|
| 966 |
+
}
|
| 967 |
+
|
| 968 |
+
function renderUsers(users) {
|
| 969 |
+
const tbody = document.getElementById('userTableBody');
|
| 970 |
+
|
| 971 |
+
if (!users || users.length === 0) {
|
| 972 |
+
tbody.innerHTML = `
|
| 973 |
+
<tr>
|
| 974 |
+
<td colspan="9" style="text-align: center; padding: 40px;">
|
| 975 |
+
<div style="font-size: 3em; margin-bottom: 15px;">👥</div>
|
| 976 |
+
<p style="font-weight: 600;">暂无用户</p>
|
| 977 |
+
</td>
|
| 978 |
+
</tr>
|
| 979 |
+
`;
|
| 980 |
+
return;
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
tbody.innerHTML = users.map(user => {
|
| 984 |
+
const createdAt = new Date(user.created_at).toLocaleString('zh-CN');
|
| 985 |
+
const expiresAt = user.expires_at ? new Date(user.expires_at).toLocaleString('zh-CN') : '永久';
|
| 986 |
+
const lastLogin = user.last_login ? new Date(user.last_login).toLocaleString('zh-CN') : '从未登录';
|
| 987 |
+
|
| 988 |
+
// ✅ 用户类型显示
|
| 989 |
+
const userTypeBadge = user.is_admin
|
| 990 |
+
? '<span class="badge badge-admin">👑 管理员</span>'
|
| 991 |
+
: '<span class="badge badge-success">👤 普通用户</span>';
|
| 992 |
+
|
| 993 |
+
let statusBadge = '';
|
| 994 |
+
if (!user.is_active) {
|
| 995 |
+
statusBadge = '<span class="badge badge-danger">已禁用</span>';
|
| 996 |
+
} else if (user.expires_at && new Date(user.expires_at) < new Date()) {
|
| 997 |
+
statusBadge = '<span class="badge badge-warning">已过期</span>';
|
| 998 |
+
} else {
|
| 999 |
+
statusBadge = '<span class="badge badge-success">正常</span>';
|
| 1000 |
+
}
|
| 1001 |
+
|
| 1002 |
+
let userBadgeHtml = '-';
|
| 1003 |
+
if (user.badge && availableBadges[user.badge]) {
|
| 1004 |
+
const badge = availableBadges[user.badge];
|
| 1005 |
+
userBadgeHtml = `
|
| 1006 |
+
<div class="user-badge" style="
|
| 1007 |
+
background: ${badge.gradient};
|
| 1008 |
+
color: ${badge.color};
|
| 1009 |
+
border: 2px solid ${badge.border};
|
| 1010 |
+
box-shadow: 0 2px 8px ${badge.glow};
|
| 1011 |
+
">
|
| 1012 |
+
<span>${badge.icon}</span>
|
| 1013 |
+
<span>${badge.name}</span>
|
| 1014 |
+
</div>
|
| 1015 |
+
`;
|
| 1016 |
+
}
|
| 1017 |
+
|
| 1018 |
+
return `
|
| 1019 |
+
<tr>
|
| 1020 |
+
<td><strong>${user.username}</strong></td>
|
| 1021 |
+
<td>${userTypeBadge}</td>
|
| 1022 |
+
<td>${userBadgeHtml}</td>
|
| 1023 |
+
<td>${createdAt}</td>
|
| 1024 |
+
<td>${expiresAt}</td>
|
| 1025 |
+
<td>${lastLogin}</td>
|
| 1026 |
+
<td>${statusBadge}</td>
|
| 1027 |
+
<td>${user.notes || '-'}</td>
|
| 1028 |
+
<td>
|
| 1029 |
+
<div class="action-buttons">
|
| 1030 |
+
<button class="btn btn-primary" onclick="openBadgeModal('${user.username}')">🏷️</button>
|
| 1031 |
+
${user.is_active ?
|
| 1032 |
+
`<button class="btn btn-warning" onclick="toggleUser('${user.username}', false)">禁用</button>` :
|
| 1033 |
+
`<button class="btn btn-success" onclick="toggleUser('${user.username}', true)">启用</button>`
|
| 1034 |
+
}
|
| 1035 |
+
<button class="btn btn-primary" onclick="extendExpiry('${user.username}')">续期</button>
|
| 1036 |
+
<button class="btn btn-danger" onclick="deleteUser('${user.username}')">删除</button>
|
| 1037 |
+
</div>
|
| 1038 |
+
</td>
|
| 1039 |
+
</tr>
|
| 1040 |
+
`;
|
| 1041 |
+
}).join('');
|
| 1042 |
+
}
|
| 1043 |
+
|
| 1044 |
+
async function createUser(username, password, expiryDays, notes, badge, isAdmin) {
|
| 1045 |
+
const token = getAdminToken();
|
| 1046 |
+
|
| 1047 |
+
const response = await fetch(`${API}/api/admin/users`, {
|
| 1048 |
+
method: 'POST',
|
| 1049 |
+
headers: {
|
| 1050 |
+
'Authorization': `Bearer ${token}`,
|
| 1051 |
+
'Content-Type': 'application/json'
|
| 1052 |
+
},
|
| 1053 |
+
body: JSON.stringify({
|
| 1054 |
+
username,
|
| 1055 |
+
password: password || null,
|
| 1056 |
+
expires_days: expiryDays ? parseInt(expiryDays) : null,
|
| 1057 |
+
notes,
|
| 1058 |
+
badge: badge || null,
|
| 1059 |
+
is_admin: isAdmin // ✅ 添加管理员标识
|
| 1060 |
+
})
|
| 1061 |
+
});
|
| 1062 |
+
|
| 1063 |
+
if (!response.ok) {
|
| 1064 |
+
const error = await response.json();
|
| 1065 |
+
throw new Error(error.error || '创建失败');
|
| 1066 |
+
}
|
| 1067 |
+
|
| 1068 |
+
return await response.json();
|
| 1069 |
+
}
|
| 1070 |
+
|
| 1071 |
+
window.toggleUser = async function(username, activate) {
|
| 1072 |
+
const token = getAdminToken();
|
| 1073 |
+
|
| 1074 |
+
if (!token) {
|
| 1075 |
+
showNotification('⚠️ 未登录,请重新登录', 'error');
|
| 1076 |
+
setTimeout(() => window.location.href = '/admin/login', 1500);
|
| 1077 |
+
return;
|
| 1078 |
+
}
|
| 1079 |
+
|
| 1080 |
+
const action = activate ? 'activate' : 'deactivate';
|
| 1081 |
+
const actionText = activate ? '启用' : '禁用';
|
| 1082 |
+
|
| 1083 |
+
if (!confirm(`确定要${actionText}用户 ${username} 吗?`)) return;
|
| 1084 |
+
|
| 1085 |
+
try {
|
| 1086 |
+
const response = await fetch(`${API}/api/admin/users/${username}/${action}`, {
|
| 1087 |
+
method: 'POST',
|
| 1088 |
+
headers: {
|
| 1089 |
+
'Authorization': `Bearer ${token}`,
|
| 1090 |
+
'Content-Type': 'application/json'
|
| 1091 |
+
}
|
| 1092 |
+
});
|
| 1093 |
+
|
| 1094 |
+
if (response.status === 401) {
|
| 1095 |
+
clearAuth();
|
| 1096 |
+
showNotification('❌ 登录已过期,请重新登录', 'error');
|
| 1097 |
+
setTimeout(() => window.location.href = '/admin/login', 1500);
|
| 1098 |
+
return;
|
| 1099 |
+
}
|
| 1100 |
+
|
| 1101 |
+
if (response.ok) {
|
| 1102 |
+
showNotification(`✅ 用户已${actionText}`, 'success');
|
| 1103 |
+
await loadUsers();
|
| 1104 |
+
} else {
|
| 1105 |
+
const errorData = await response.json();
|
| 1106 |
+
throw new Error(errorData.error || `${actionText}失败`);
|
| 1107 |
+
}
|
| 1108 |
+
} catch (error) {
|
| 1109 |
+
showNotification(`❌ ${error.message}`, 'error');
|
| 1110 |
+
}
|
| 1111 |
+
};
|
| 1112 |
+
|
| 1113 |
+
window.deleteUser = async function(username) {
|
| 1114 |
+
if (!confirm(`⚠️ 确定要删除用户 ${username} 吗?\n\n此操作不可恢复!`)) return;
|
| 1115 |
+
|
| 1116 |
+
const token = getAdminToken();
|
| 1117 |
+
|
| 1118 |
+
if (!token) {
|
| 1119 |
+
showNotification('⚠️ 未登录,请重新登录', 'error');
|
| 1120 |
+
setTimeout(() => window.location.href = '/admin/login', 1500);
|
| 1121 |
+
return;
|
| 1122 |
+
}
|
| 1123 |
+
|
| 1124 |
+
try {
|
| 1125 |
+
const response = await fetch(`${API}/api/admin/users/${username}`, {
|
| 1126 |
+
method: 'DELETE',
|
| 1127 |
+
headers: {
|
| 1128 |
+
'Authorization': `Bearer ${token}`,
|
| 1129 |
+
'Content-Type': 'application/json'
|
| 1130 |
+
}
|
| 1131 |
+
});
|
| 1132 |
+
|
| 1133 |
+
if (response.status === 401) {
|
| 1134 |
+
clearAuth();
|
| 1135 |
+
showNotification('❌ 登录已过期,请重新登录', 'error');
|
| 1136 |
+
setTimeout(() => window.location.href = '/admin/login', 1500);
|
| 1137 |
+
return;
|
| 1138 |
+
}
|
| 1139 |
+
|
| 1140 |
+
if (response.ok) {
|
| 1141 |
+
showNotification('✅ 用户已删除', 'success');
|
| 1142 |
+
await loadUsers();
|
| 1143 |
+
} else {
|
| 1144 |
+
const errorData = await response.json();
|
| 1145 |
+
throw new Error(errorData.error || '删除失败');
|
| 1146 |
+
}
|
| 1147 |
+
} catch (error) {
|
| 1148 |
+
showNotification(`❌ ${error.message}`, 'error');
|
| 1149 |
+
}
|
| 1150 |
+
};
|
| 1151 |
+
|
| 1152 |
+
window.extendExpiry = async function(username) {
|
| 1153 |
+
const days = prompt('请输入要延长的天数:', '30');
|
| 1154 |
+
if (!days || isNaN(days) || parseInt(days) <= 0) return;
|
| 1155 |
+
|
| 1156 |
+
const token = getAdminToken();
|
| 1157 |
+
|
| 1158 |
+
if (!token) {
|
| 1159 |
+
showNotification('⚠️ 未登录,请重新登录', 'error');
|
| 1160 |
+
setTimeout(() => window.location.href = '/admin/login', 1500);
|
| 1161 |
+
return;
|
| 1162 |
+
}
|
| 1163 |
+
|
| 1164 |
+
try {
|
| 1165 |
+
const response = await fetch(`${API}/api/admin/users/${username}/extend`, {
|
| 1166 |
+
method: 'POST',
|
| 1167 |
+
headers: {
|
| 1168 |
+
'Authorization': `Bearer ${token}`,
|
| 1169 |
+
'Content-Type': 'application/json'
|
| 1170 |
+
},
|
| 1171 |
+
body: JSON.stringify({ days: parseInt(days) })
|
| 1172 |
+
});
|
| 1173 |
+
|
| 1174 |
+
if (response.status === 401) {
|
| 1175 |
+
clearAuth();
|
| 1176 |
+
showNotification('❌ 登录已过期,请重新登录', 'error');
|
| 1177 |
+
setTimeout(() => window.location.href = '/admin/login', 1500);
|
| 1178 |
+
return;
|
| 1179 |
+
}
|
| 1180 |
+
|
| 1181 |
+
if (response.ok) {
|
| 1182 |
+
showNotification(`✅ 已为用户 ${username} 延长 ${days} 天`, 'success');
|
| 1183 |
+
await loadUsers();
|
| 1184 |
+
} else {
|
| 1185 |
+
const errorData = await response.json();
|
| 1186 |
+
throw new Error(errorData.error || '续期失败');
|
| 1187 |
+
}
|
| 1188 |
+
} catch (error) {
|
| 1189 |
+
showNotification(`❌ ${error.message}`, 'error');
|
| 1190 |
+
}
|
| 1191 |
+
};
|
| 1192 |
+
|
| 1193 |
+
window.copyPassword = function() {
|
| 1194 |
+
const password = document.getElementById('displayPassword').textContent;
|
| 1195 |
+
navigator.clipboard.writeText(password).then(() => {
|
| 1196 |
+
showNotification('✅ 密码已复制', 'success');
|
| 1197 |
+
}).catch(() => {
|
| 1198 |
+
showNotification('❌ 复制失败', 'error');
|
| 1199 |
+
});
|
| 1200 |
+
};
|
| 1201 |
+
|
| 1202 |
+
window.closePasswordModal = function() {
|
| 1203 |
+
document.getElementById('passwordModal').classList.remove('show');
|
| 1204 |
+
};
|
| 1205 |
+
|
| 1206 |
+
window.loadUsers = loadUsers;
|
| 1207 |
+
|
| 1208 |
+
function handleCreateUserForm() {
|
| 1209 |
+
const form = document.getElementById('createUserForm');
|
| 1210 |
+
if (!form) return;
|
| 1211 |
+
|
| 1212 |
+
// ✅ 处理自定义天数显示/隐藏
|
| 1213 |
+
const expiryDays = document.getElementById('expiryDays');
|
| 1214 |
+
const customDaysGroup = document.getElementById('customDaysGroup');
|
| 1215 |
+
|
| 1216 |
+
if (expiryDays) {
|
| 1217 |
+
expiryDays.addEventListener('change', (e) => {
|
| 1218 |
+
if (e.target.value === 'custom') {
|
| 1219 |
+
customDaysGroup.style.display = 'block';
|
| 1220 |
+
} else {
|
| 1221 |
+
customDaysGroup.style.display = 'none';
|
| 1222 |
+
}
|
| 1223 |
+
});
|
| 1224 |
+
}
|
| 1225 |
+
|
| 1226 |
+
form.addEventListener('submit', async (e) => {
|
| 1227 |
+
e.preventDefault();
|
| 1228 |
+
|
| 1229 |
+
const username = document.getElementById('username').value.trim();
|
| 1230 |
+
const password = document.getElementById('password').value.trim();
|
| 1231 |
+
let expiryDaysValue = document.getElementById('expiryDays').value;
|
| 1232 |
+
const notes = document.getElementById('notes').value.trim();
|
| 1233 |
+
const badge = document.getElementById('selectedBadge').value;
|
| 1234 |
+
const userType = document.getElementById('userType').value;
|
| 1235 |
+
|
| 1236 |
+
// ✅ 处理自定义天数
|
| 1237 |
+
if (expiryDaysValue === 'custom') {
|
| 1238 |
+
const customDays = document.getElementById('customDays').value;
|
| 1239 |
+
if (!customDays || parseInt(customDays) <= 0) {
|
| 1240 |
+
showNotification('⚠️ 请输入有效的自定义天数', 'error');
|
| 1241 |
+
return;
|
| 1242 |
+
}
|
| 1243 |
+
expiryDaysValue = customDays;
|
| 1244 |
+
}
|
| 1245 |
+
|
| 1246 |
+
if (!username) {
|
| 1247 |
+
showNotification('⚠️ 请输入用户名', 'error');
|
| 1248 |
+
return;
|
| 1249 |
+
}
|
| 1250 |
+
|
| 1251 |
+
const submitBtn = form.querySelector('button[type="submit"]');
|
| 1252 |
+
const originalText = submitBtn.textContent;
|
| 1253 |
+
submitBtn.disabled = true;
|
| 1254 |
+
submitBtn.textContent = '创建中...';
|
| 1255 |
+
|
| 1256 |
+
try {
|
| 1257 |
+
const isAdmin = userType === 'admin';
|
| 1258 |
+
const data = await createUser(username, password, expiryDaysValue, notes, badge, isAdmin);
|
| 1259 |
+
|
| 1260 |
+
document.getElementById('displayUsername').textContent = username;
|
| 1261 |
+
document.getElementById('displayPassword').textContent = data.password;
|
| 1262 |
+
document.getElementById('passwordModal').classList.add('show');
|
| 1263 |
+
|
| 1264 |
+
form.reset();
|
| 1265 |
+
selectBadge('');
|
| 1266 |
+
|
| 1267 |
+
await loadUsers();
|
| 1268 |
+
showNotification('✅ 用户创建成功', 'success');
|
| 1269 |
+
} catch (error) {
|
| 1270 |
+
showNotification(`❌ 创建失败: ${error.message}`, 'error');
|
| 1271 |
+
} finally {
|
| 1272 |
+
submitBtn.disabled = false;
|
| 1273 |
+
submitBtn.textContent = originalText;
|
| 1274 |
+
}
|
| 1275 |
+
});
|
| 1276 |
+
}
|
| 1277 |
+
|
| 1278 |
+
async function initialize() {
|
| 1279 |
+
showLoading('验证身份中...');
|
| 1280 |
+
|
| 1281 |
+
try {
|
| 1282 |
+
const isAuthenticated = await checkAuth();
|
| 1283 |
+
|
| 1284 |
+
if (!isAuthenticated) {
|
| 1285 |
+
showLoading('未登录,正在跳转...');
|
| 1286 |
+
redirectToLogin();
|
| 1287 |
+
return;
|
| 1288 |
+
}
|
| 1289 |
+
|
| 1290 |
+
const logoutBtn = document.getElementById('logoutBtn');
|
| 1291 |
+
if (logoutBtn) {
|
| 1292 |
+
logoutBtn.addEventListener('click', logout);
|
| 1293 |
+
}
|
| 1294 |
+
|
| 1295 |
+
handleCreateUserForm();
|
| 1296 |
+
|
| 1297 |
+
showLoading('加载数据...');
|
| 1298 |
+
|
| 1299 |
+
await loadBadges();
|
| 1300 |
+
await loadUsers();
|
| 1301 |
+
|
| 1302 |
+
hideLoading();
|
| 1303 |
+
|
| 1304 |
+
} catch (error) {
|
| 1305 |
+
showLoading('初始化失败...');
|
| 1306 |
+
setTimeout(() => {
|
| 1307 |
+
window.location.reload();
|
| 1308 |
+
}, 2000);
|
| 1309 |
+
}
|
| 1310 |
+
}
|
| 1311 |
+
|
| 1312 |
+
if (document.readyState === 'loading') {
|
| 1313 |
+
document.addEventListener('DOMContentLoaded', initialize);
|
| 1314 |
+
} else {
|
| 1315 |
+
initialize();
|
| 1316 |
+
}
|
| 1317 |
+
|
| 1318 |
+
})();
|
| 1319 |
+
</script>
|
| 1320 |
+
</body>
|
| 1321 |
+
</html>
|
static/css/style.css
ADDED
|
@@ -0,0 +1,1157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
| 2 |
+
|
| 3 |
+
@keyframes shimmer {
|
| 4 |
+
0% { background-position: -468px 0; }
|
| 5 |
+
100% { background-position: 468px 0; }
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.skeleton {
|
| 9 |
+
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
| 10 |
+
background-size: 468px 100%;
|
| 11 |
+
animation: shimmer 1.2s ease-in-out infinite;
|
| 12 |
+
border-radius: 8px;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.skeleton-text { height: 16px; margin-bottom: 8px; }
|
| 16 |
+
.skeleton-title { height: 24px; width: 60%; margin-bottom: 12px; }
|
| 17 |
+
|
| 18 |
+
/* 性能优化 */
|
| 19 |
+
.notification, .modal-overlay, .loading-overlay {
|
| 20 |
+
will-change: opacity, transform;
|
| 21 |
+
transform: translateZ(0);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.channel-item, .epg-item {
|
| 25 |
+
contain: layout style paint;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/* 优化动画 */
|
| 29 |
+
.fab-button {
|
| 30 |
+
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.fab-button:hover {
|
| 34 |
+
transform: translateY(-4px);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
:root {
|
| 38 |
+
--primary: #6366f1;
|
| 39 |
+
--primary-dark: #4f46e5;
|
| 40 |
+
--success: #10b981;
|
| 41 |
+
--warning: #f59e0b;
|
| 42 |
+
--danger: #ef4444;
|
| 43 |
+
--dark: #1e293b;
|
| 44 |
+
--light: #f8fafc;
|
| 45 |
+
--border: #e2e8f0;
|
| 46 |
+
--shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
* {
|
| 50 |
+
margin: 0;
|
| 51 |
+
padding: 0;
|
| 52 |
+
box-sizing: border-box;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
body {
|
| 56 |
+
font-family: 'Inter', -apple-system, sans-serif;
|
| 57 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 58 |
+
background-attachment: fixed;
|
| 59 |
+
min-height: 100vh;
|
| 60 |
+
line-height: 1.5;
|
| 61 |
+
font-size: 14px;
|
| 62 |
+
color: var(--dark);
|
| 63 |
+
-webkit-font-smoothing: antialiased;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
*, *::before, *::after {
|
| 67 |
+
animation: none !important;
|
| 68 |
+
transition: none !important;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.notification,
|
| 72 |
+
.login-overlay,
|
| 73 |
+
.epg-video-modal {
|
| 74 |
+
transition: opacity 0.15s ease !important;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.app-container {
|
| 78 |
+
display: flex;
|
| 79 |
+
flex-direction: column;
|
| 80 |
+
min-height: 100vh;
|
| 81 |
+
max-width: 1600px;
|
| 82 |
+
margin: 20px auto;
|
| 83 |
+
background: rgba(255, 255, 255, 0.95);
|
| 84 |
+
border-radius: 20px;
|
| 85 |
+
box-shadow: var(--shadow);
|
| 86 |
+
overflow: hidden;
|
| 87 |
+
contain: layout style paint;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.app-body {
|
| 91 |
+
flex: 1;
|
| 92 |
+
padding: 20px 30px;
|
| 93 |
+
transform: translateZ(0);
|
| 94 |
+
will-change: scroll-position;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.header {
|
| 98 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 99 |
+
color: white;
|
| 100 |
+
padding: 20px 30px;
|
| 101 |
+
contain: layout style paint;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.header-top {
|
| 105 |
+
display: flex;
|
| 106 |
+
justify-content: space-between;
|
| 107 |
+
align-items: center;
|
| 108 |
+
margin-bottom: 15px;
|
| 109 |
+
gap: 15px;
|
| 110 |
+
flex-wrap: wrap;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.header h1 {
|
| 114 |
+
font-size: 1.8em;
|
| 115 |
+
font-weight: 700;
|
| 116 |
+
margin-bottom: 5px;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.subtitle {
|
| 120 |
+
font-size: 0.9em;
|
| 121 |
+
opacity: 0.9;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.status-bar {
|
| 125 |
+
display: flex;
|
| 126 |
+
justify-content: center;
|
| 127 |
+
gap: 20px;
|
| 128 |
+
flex-wrap: wrap;
|
| 129 |
+
font-size: 0.85em;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.status-item {
|
| 133 |
+
background: rgba(255, 255, 255, 0.2);
|
| 134 |
+
padding: 8px 15px;
|
| 135 |
+
border-radius: 20px;
|
| 136 |
+
display: flex;
|
| 137 |
+
align-items: center;
|
| 138 |
+
gap: 8px;
|
| 139 |
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.btn-logout {
|
| 143 |
+
padding: 10px 20px;
|
| 144 |
+
background: rgba(255, 255, 255, 0.2);
|
| 145 |
+
color: white;
|
| 146 |
+
border: 2px solid rgba(255, 255, 255, 0.5);
|
| 147 |
+
border-radius: 20px;
|
| 148 |
+
cursor: pointer;
|
| 149 |
+
font-size: 0.9em;
|
| 150 |
+
font-weight: 600;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.btn-logout:hover {
|
| 154 |
+
background: rgba(255, 255, 255, 0.3);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.user-type-badge {
|
| 158 |
+
display: inline-block;
|
| 159 |
+
padding: 3px 10px;
|
| 160 |
+
border-radius: 15px;
|
| 161 |
+
font-size: 0.7em;
|
| 162 |
+
font-weight: 700;
|
| 163 |
+
margin-left: 8px;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.user-type-badge.admin {
|
| 167 |
+
background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
|
| 168 |
+
color: white;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.user-type-badge.user {
|
| 172 |
+
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
| 173 |
+
color: white;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.tabs {
|
| 177 |
+
display: flex;
|
| 178 |
+
background: rgba(248, 250, 252, 0.8);
|
| 179 |
+
border-bottom: 1px solid var(--border);
|
| 180 |
+
overflow-x: auto;
|
| 181 |
+
scrollbar-width: none;
|
| 182 |
+
contain: layout style;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.tabs::-webkit-scrollbar {
|
| 186 |
+
display: none;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.tab-button {
|
| 190 |
+
flex: 1;
|
| 191 |
+
padding: 15px 18px;
|
| 192 |
+
background: transparent;
|
| 193 |
+
border: none;
|
| 194 |
+
cursor: pointer;
|
| 195 |
+
font-size: 0.9em;
|
| 196 |
+
color: var(--dark);
|
| 197 |
+
font-weight: 500;
|
| 198 |
+
text-decoration: none;
|
| 199 |
+
text-align: center;
|
| 200 |
+
min-width: 120px;
|
| 201 |
+
border-bottom: 3px solid transparent;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.tab-button:hover {
|
| 205 |
+
background: rgba(99, 102, 241, 0.05);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.tab-button.active {
|
| 209 |
+
background: white;
|
| 210 |
+
font-weight: 600;
|
| 211 |
+
color: var(--primary);
|
| 212 |
+
border-bottom-color: var(--primary);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.btn {
|
| 216 |
+
padding: 10px 20px;
|
| 217 |
+
border: none;
|
| 218 |
+
border-radius: 10px;
|
| 219 |
+
cursor: pointer;
|
| 220 |
+
font-size: 0.9em;
|
| 221 |
+
font-weight: 600;
|
| 222 |
+
contain: layout style paint;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.btn:hover:not(:disabled) {
|
| 226 |
+
opacity: 0.9;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.btn:disabled {
|
| 230 |
+
opacity: 0.6;
|
| 231 |
+
cursor: not-allowed;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.btn-primary {
|
| 235 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 236 |
+
color: white;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.btn-success {
|
| 240 |
+
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
| 241 |
+
color: white;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.btn-warning {
|
| 245 |
+
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
| 246 |
+
color: white;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.btn-danger {
|
| 250 |
+
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
| 251 |
+
color: white;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.form-control {
|
| 255 |
+
width: 100%;
|
| 256 |
+
padding: 12px 15px;
|
| 257 |
+
border: 2px solid var(--border);
|
| 258 |
+
border-radius: 10px;
|
| 259 |
+
font-size: 0.9em;
|
| 260 |
+
background: white;
|
| 261 |
+
font-family: inherit;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.form-control:focus {
|
| 265 |
+
outline: none;
|
| 266 |
+
border-color: var(--primary);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
.form-group {
|
| 270 |
+
margin-bottom: 15px;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
.form-group label {
|
| 274 |
+
display: block;
|
| 275 |
+
margin-bottom: 6px;
|
| 276 |
+
font-weight: 600;
|
| 277 |
+
color: var(--dark);
|
| 278 |
+
font-size: 0.85em;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.section-header {
|
| 282 |
+
display: flex;
|
| 283 |
+
justify-content: space-between;
|
| 284 |
+
align-items: center;
|
| 285 |
+
margin-bottom: 20px;
|
| 286 |
+
gap: 15px;
|
| 287 |
+
flex-wrap: wrap;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.stats-box {
|
| 291 |
+
background: rgba(99, 102, 241, 0.1);
|
| 292 |
+
padding: 12px 20px;
|
| 293 |
+
border-radius: 12px;
|
| 294 |
+
margin-bottom: 20px;
|
| 295 |
+
font-size: 0.9em;
|
| 296 |
+
border: 1px solid rgba(99, 102, 241, 0.2);
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.channel-grid {
|
| 300 |
+
display: grid;
|
| 301 |
+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
| 302 |
+
gap: 15px;
|
| 303 |
+
content-visibility: auto;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.channel-card {
|
| 307 |
+
background: white;
|
| 308 |
+
border: 2px solid transparent;
|
| 309 |
+
border-radius: 15px;
|
| 310 |
+
padding: 20px 15px;
|
| 311 |
+
cursor: pointer;
|
| 312 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
| 313 |
+
contain: layout style paint;
|
| 314 |
+
content-visibility: auto;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
.channel-card:hover {
|
| 318 |
+
border-color: var(--primary);
|
| 319 |
+
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
.channel-name {
|
| 323 |
+
font-size: 1em;
|
| 324 |
+
color: var(--dark);
|
| 325 |
+
margin-bottom: 12px;
|
| 326 |
+
font-weight: 600;
|
| 327 |
+
text-align: center;
|
| 328 |
+
min-height: 45px;
|
| 329 |
+
display: flex;
|
| 330 |
+
align-items: center;
|
| 331 |
+
justify-content: center;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.channel-actions {
|
| 335 |
+
display: flex;
|
| 336 |
+
gap: 8px;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.channel-actions .btn {
|
| 340 |
+
flex: 1;
|
| 341 |
+
padding: 8px;
|
| 342 |
+
font-size: 0.85em;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
.player-page {
|
| 346 |
+
display: flex;
|
| 347 |
+
flex-direction: column;
|
| 348 |
+
gap: 15px;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.player-controls {
|
| 352 |
+
display: grid;
|
| 353 |
+
grid-template-columns: 1fr auto;
|
| 354 |
+
gap: 12px;
|
| 355 |
+
align-items: end;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.video-wrapper {
|
| 359 |
+
position: relative;
|
| 360 |
+
background: #1e293b;
|
| 361 |
+
border-radius: 15px;
|
| 362 |
+
overflow: hidden;
|
| 363 |
+
aspect-ratio: 16 / 9;
|
| 364 |
+
max-height: 550px;
|
| 365 |
+
contain: layout style;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
video {
|
| 369 |
+
width: 100%;
|
| 370 |
+
height: 100%;
|
| 371 |
+
display: block;
|
| 372 |
+
object-fit: contain;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.video-placeholder {
|
| 376 |
+
position: absolute;
|
| 377 |
+
top: 0;
|
| 378 |
+
left: 0;
|
| 379 |
+
width: 100%;
|
| 380 |
+
height: 100%;
|
| 381 |
+
background: #1e293b;
|
| 382 |
+
display: flex;
|
| 383 |
+
align-items: center;
|
| 384 |
+
justify-content: center;
|
| 385 |
+
color: white;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
.video-placeholder.hidden {
|
| 389 |
+
display: none;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
.placeholder-icon {
|
| 393 |
+
font-size: 50px;
|
| 394 |
+
margin-bottom: 12px;
|
| 395 |
+
opacity: 0.6;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.stream-info-panel {
|
| 399 |
+
display: grid;
|
| 400 |
+
grid-template-columns: 1fr 1fr;
|
| 401 |
+
gap: 15px;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.info-section,
|
| 405 |
+
.record-section {
|
| 406 |
+
background: rgba(255, 255, 255, 0.95);
|
| 407 |
+
padding: 20px;
|
| 408 |
+
border-radius: 15px;
|
| 409 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 410 |
+
contain: layout style paint;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
.info-section h3,
|
| 414 |
+
.record-section h3 {
|
| 415 |
+
margin-bottom: 12px;
|
| 416 |
+
color: var(--dark);
|
| 417 |
+
font-size: 1em;
|
| 418 |
+
font-weight: 700;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
.info-content {
|
| 422 |
+
background: #f8fafc;
|
| 423 |
+
padding: 12px;
|
| 424 |
+
border-radius: 10px;
|
| 425 |
+
font-family: 'Courier New', monospace;
|
| 426 |
+
font-size: 0.8em;
|
| 427 |
+
line-height: 1.5;
|
| 428 |
+
white-space: pre-wrap;
|
| 429 |
+
word-break: break-all;
|
| 430 |
+
min-height: 100px;
|
| 431 |
+
max-height: 250px;
|
| 432 |
+
overflow-y: auto;
|
| 433 |
+
border: 1px solid #e2e8f0;
|
| 434 |
+
color: var(--dark);
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
.btn-record {
|
| 438 |
+
width: 100%;
|
| 439 |
+
padding: 14px;
|
| 440 |
+
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
| 441 |
+
color: white;
|
| 442 |
+
border: none;
|
| 443 |
+
border-radius: 12px;
|
| 444 |
+
cursor: pointer;
|
| 445 |
+
font-size: 0.95em;
|
| 446 |
+
font-weight: 700;
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
.btn-record:hover:not(:disabled) {
|
| 450 |
+
opacity: 0.9;
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
.btn-record:disabled {
|
| 454 |
+
background: linear-gradient(135deg, #94a3b8 0%, #64748b 100%);
|
| 455 |
+
cursor: not-allowed;
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
.record-status {
|
| 459 |
+
margin-top: 12px;
|
| 460 |
+
padding: 12px;
|
| 461 |
+
border-radius: 10px;
|
| 462 |
+
font-size: 0.85em;
|
| 463 |
+
text-align: center;
|
| 464 |
+
display: none;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
.record-status.show {
|
| 468 |
+
display: block;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.record-status.recording {
|
| 472 |
+
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
| 473 |
+
color: #92400e;
|
| 474 |
+
border: 2px solid #fbbf24;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
.record-status.success {
|
| 478 |
+
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
| 479 |
+
color: #065f46;
|
| 480 |
+
border: 2px solid #10b981;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
.record-status.error {
|
| 484 |
+
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
|
| 485 |
+
color: #991b1b;
|
| 486 |
+
border: 2px solid #ef4444;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
.record-info {
|
| 490 |
+
background: rgba(219, 234, 254, 0.5);
|
| 491 |
+
padding: 12px;
|
| 492 |
+
border-radius: 10px;
|
| 493 |
+
margin-top: 12px;
|
| 494 |
+
font-size: 0.85em;
|
| 495 |
+
line-height: 1.5;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
.epg-page {
|
| 499 |
+
display: flex;
|
| 500 |
+
flex-direction: column;
|
| 501 |
+
gap: 15px;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
.epg-controls {
|
| 505 |
+
display: grid;
|
| 506 |
+
grid-template-columns: 1fr 1fr auto;
|
| 507 |
+
gap: 12px;
|
| 508 |
+
align-items: end;
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
.epg-list {
|
| 512 |
+
max-height: 550px;
|
| 513 |
+
overflow-y: auto;
|
| 514 |
+
background: rgba(248, 250, 252, 0.5);
|
| 515 |
+
border-radius: 15px;
|
| 516 |
+
padding: 15px;
|
| 517 |
+
content-visibility: auto;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
.epg-item {
|
| 521 |
+
background: white;
|
| 522 |
+
padding: 15px;
|
| 523 |
+
margin-bottom: 12px;
|
| 524 |
+
border-radius: 12px;
|
| 525 |
+
border-left: 4px solid var(--primary);
|
| 526 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
| 527 |
+
contain: layout style paint;
|
| 528 |
+
content-visibility: auto;
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
.epg-item:hover {
|
| 532 |
+
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15);
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
.epg-time {
|
| 536 |
+
font-weight: 700;
|
| 537 |
+
color: var(--primary);
|
| 538 |
+
margin-bottom: 6px;
|
| 539 |
+
font-size: 0.9em;
|
| 540 |
+
display: flex;
|
| 541 |
+
align-items: center;
|
| 542 |
+
gap: 8px;
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
.epg-title {
|
| 546 |
+
font-size: 1em;
|
| 547 |
+
color: var(--dark);
|
| 548 |
+
margin-bottom: 6px;
|
| 549 |
+
font-weight: 600;
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
.epg-description {
|
| 553 |
+
color: #64748b;
|
| 554 |
+
font-size: 0.85em;
|
| 555 |
+
line-height: 1.4;
|
| 556 |
+
margin-top: 6px;
|
| 557 |
+
padding: 10px;
|
| 558 |
+
background: #f8fafc;
|
| 559 |
+
border-radius: 8px;
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
.epg-item.current {
|
| 563 |
+
border-left-color: var(--success);
|
| 564 |
+
background: linear-gradient(135deg, #d1fae5 0%, white 100%);
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
.epg-item.current .epg-time {
|
| 568 |
+
color: var(--success);
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
.epg-item.past {
|
| 572 |
+
opacity: 0.7;
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
.epg-actions {
|
| 576 |
+
margin-top: 10px;
|
| 577 |
+
display: flex;
|
| 578 |
+
gap: 8px;
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
.epg-actions .btn {
|
| 582 |
+
flex: 1;
|
| 583 |
+
padding: 8px 12px;
|
| 584 |
+
font-size: 0.85em;
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
.epg-video-modal {
|
| 588 |
+
display: none;
|
| 589 |
+
position: fixed;
|
| 590 |
+
top: 0;
|
| 591 |
+
left: 0;
|
| 592 |
+
width: 100%;
|
| 593 |
+
height: 100%;
|
| 594 |
+
background: rgba(30, 41, 59, 0.95);
|
| 595 |
+
z-index: 9999;
|
| 596 |
+
align-items: center;
|
| 597 |
+
justify-content: center;
|
| 598 |
+
padding: 20px;
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
.epg-video-modal.show {
|
| 602 |
+
display: flex;
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
.epg-video-container {
|
| 606 |
+
background: white;
|
| 607 |
+
border-radius: 20px;
|
| 608 |
+
max-width: 1100px;
|
| 609 |
+
width: 100%;
|
| 610 |
+
max-height: 90vh;
|
| 611 |
+
overflow: hidden;
|
| 612 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
.epg-video-header {
|
| 616 |
+
padding: 15px 25px;
|
| 617 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 618 |
+
color: white;
|
| 619 |
+
display: flex;
|
| 620 |
+
justify-content: space-between;
|
| 621 |
+
align-items: center;
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
.epg-video-header h3 {
|
| 625 |
+
font-size: 1.2em;
|
| 626 |
+
font-weight: 700;
|
| 627 |
+
margin: 0;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
.epg-video-close {
|
| 631 |
+
background: rgba(255, 255, 255, 0.2);
|
| 632 |
+
border: none;
|
| 633 |
+
color: white;
|
| 634 |
+
font-size: 1.4em;
|
| 635 |
+
width: 35px;
|
| 636 |
+
height: 35px;
|
| 637 |
+
border-radius: 50%;
|
| 638 |
+
cursor: pointer;
|
| 639 |
+
display: flex;
|
| 640 |
+
align-items: center;
|
| 641 |
+
justify-content: center;
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
.epg-video-close:hover {
|
| 645 |
+
background: rgba(255, 255, 255, 0.3);
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
.epg-video-body {
|
| 649 |
+
padding: 0;
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
.epg-video-wrapper {
|
| 653 |
+
position: relative;
|
| 654 |
+
background: #000;
|
| 655 |
+
aspect-ratio: 16 / 9;
|
| 656 |
+
max-height: 65vh;
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
.epg-video-wrapper video {
|
| 660 |
+
width: 100%;
|
| 661 |
+
height: 100%;
|
| 662 |
+
display: block;
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
.epg-video-info {
|
| 666 |
+
padding: 15px 25px;
|
| 667 |
+
background: #f8fafc;
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
.epg-video-info p {
|
| 671 |
+
margin: 6px 0;
|
| 672 |
+
color: var(--dark);
|
| 673 |
+
font-size: 0.9em;
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
.cache-grid {
|
| 677 |
+
display: grid;
|
| 678 |
+
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
| 679 |
+
gap: 15px;
|
| 680 |
+
margin-bottom: 25px;
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
.cache-card {
|
| 684 |
+
background: white;
|
| 685 |
+
padding: 20px;
|
| 686 |
+
border-radius: 15px;
|
| 687 |
+
border-left: 4px solid var(--primary);
|
| 688 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
| 689 |
+
contain: layout style paint;
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
.cache-card h3 {
|
| 693 |
+
color: var(--dark);
|
| 694 |
+
margin-bottom: 12px;
|
| 695 |
+
font-size: 1em;
|
| 696 |
+
font-weight: 700;
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
.cache-detail {
|
| 700 |
+
display: flex;
|
| 701 |
+
justify-content: space-between;
|
| 702 |
+
padding: 8px 0;
|
| 703 |
+
border-bottom: 1px solid var(--border);
|
| 704 |
+
font-size: 0.85em;
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
.cache-detail:last-child {
|
| 708 |
+
border-bottom: none;
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
.cache-label {
|
| 712 |
+
font-weight: 600;
|
| 713 |
+
color: #64748b;
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
.cache-value {
|
| 717 |
+
font-family: 'Courier New', monospace;
|
| 718 |
+
color: var(--dark);
|
| 719 |
+
font-weight: 600;
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
.cache-actions {
|
| 723 |
+
background: rgba(255, 255, 255, 0.95);
|
| 724 |
+
padding: 20px;
|
| 725 |
+
border-radius: 15px;
|
| 726 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
.button-group {
|
| 730 |
+
display: flex;
|
| 731 |
+
flex-wrap: wrap;
|
| 732 |
+
gap: 10px;
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
.footer {
|
| 736 |
+
background: rgba(248, 250, 252, 0.8);
|
| 737 |
+
padding: 15px;
|
| 738 |
+
text-align: center;
|
| 739 |
+
border-top: 1px solid var(--border);
|
| 740 |
+
color: #64748b;
|
| 741 |
+
font-size: 0.85em;
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
.footer a {
|
| 745 |
+
color: var(--primary);
|
| 746 |
+
text-decoration: none;
|
| 747 |
+
font-weight: 600;
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
.notification {
|
| 751 |
+
position: fixed;
|
| 752 |
+
top: 20px;
|
| 753 |
+
right: 20px;
|
| 754 |
+
padding: 12px 20px;
|
| 755 |
+
border-radius: 12px;
|
| 756 |
+
z-index: 10000;
|
| 757 |
+
color: white;
|
| 758 |
+
font-weight: 600;
|
| 759 |
+
font-size: 0.9em;
|
| 760 |
+
display: flex;
|
| 761 |
+
align-items: center;
|
| 762 |
+
gap: 10px;
|
| 763 |
+
opacity: 1;
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
+
.notification.notification-success {
|
| 767 |
+
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
.notification.notification-error {
|
| 771 |
+
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
.notification.notification-info {
|
| 775 |
+
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
.login-overlay {
|
| 779 |
+
display: flex;
|
| 780 |
+
position: fixed;
|
| 781 |
+
top: 0;
|
| 782 |
+
left: 0;
|
| 783 |
+
width: 100%;
|
| 784 |
+
height: 100%;
|
| 785 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 786 |
+
z-index: 99999;
|
| 787 |
+
align-items: center;
|
| 788 |
+
justify-content: center;
|
| 789 |
+
padding: 20px;
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
.login-overlay.hide {
|
| 793 |
+
display: none !important;
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
.login-box {
|
| 797 |
+
background: rgba(255, 255, 255, 0.95);
|
| 798 |
+
border-radius: 25px;
|
| 799 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
| 800 |
+
overflow: hidden;
|
| 801 |
+
max-width: 420px;
|
| 802 |
+
width: 100%;
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
.login-box-header {
|
| 806 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 807 |
+
color: white;
|
| 808 |
+
padding: 30px 25px;
|
| 809 |
+
text-align: center;
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
.login-box-header h1 {
|
| 813 |
+
font-size: 2em;
|
| 814 |
+
font-weight: 700;
|
| 815 |
+
margin-bottom: 8px;
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
.login-box-body {
|
| 819 |
+
padding: 30px 25px;
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
+
.login-form-group {
|
| 823 |
+
margin-bottom: 20px;
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
.login-form-group label {
|
| 827 |
+
display: block;
|
| 828 |
+
margin-bottom: 8px;
|
| 829 |
+
font-weight: 600;
|
| 830 |
+
color: var(--dark);
|
| 831 |
+
font-size: 0.9em;
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
+
.login-form-group input {
|
| 835 |
+
width: 100%;
|
| 836 |
+
padding: 12px 15px;
|
| 837 |
+
border: 2px solid var(--border);
|
| 838 |
+
border-radius: 10px;
|
| 839 |
+
font-size: 0.9em;
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
.login-form-group input:focus {
|
| 843 |
+
outline: none;
|
| 844 |
+
border-color: var(--primary);
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
.login-btn {
|
| 848 |
+
width: 100%;
|
| 849 |
+
padding: 14px;
|
| 850 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 851 |
+
color: white;
|
| 852 |
+
border: none;
|
| 853 |
+
border-radius: 10px;
|
| 854 |
+
font-size: 1em;
|
| 855 |
+
font-weight: 700;
|
| 856 |
+
cursor: pointer;
|
| 857 |
+
}
|
| 858 |
+
|
| 859 |
+
.login-btn:hover:not(:disabled) {
|
| 860 |
+
opacity: 0.9;
|
| 861 |
+
}
|
| 862 |
+
|
| 863 |
+
.login-btn:disabled {
|
| 864 |
+
opacity: 0.7;
|
| 865 |
+
cursor: not-allowed;
|
| 866 |
+
}
|
| 867 |
+
|
| 868 |
+
.login-error {
|
| 869 |
+
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
|
| 870 |
+
color: #991b1b;
|
| 871 |
+
padding: 12px;
|
| 872 |
+
border-radius: 10px;
|
| 873 |
+
margin-bottom: 15px;
|
| 874 |
+
display: none;
|
| 875 |
+
border: 2px solid #fca5a5;
|
| 876 |
+
font-size: 0.85em;
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
.login-error.show {
|
| 880 |
+
display: block;
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
.login-info {
|
| 884 |
+
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
| 885 |
+
color: #1e40af;
|
| 886 |
+
padding: 12px;
|
| 887 |
+
border-radius: 10px;
|
| 888 |
+
margin-bottom: 15px;
|
| 889 |
+
font-size: 0.85em;
|
| 890 |
+
border: 1px solid #93c5fd;
|
| 891 |
+
}
|
| 892 |
+
|
| 893 |
+
.password-toggle-login {
|
| 894 |
+
position: relative;
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
.password-toggle-btn-login {
|
| 898 |
+
position: absolute;
|
| 899 |
+
right: 12px;
|
| 900 |
+
top: 50%;
|
| 901 |
+
transform: translateY(-50%);
|
| 902 |
+
background: none;
|
| 903 |
+
border: none;
|
| 904 |
+
cursor: pointer;
|
| 905 |
+
color: #64748b;
|
| 906 |
+
font-size: 1.1em;
|
| 907 |
+
padding: 5px;
|
| 908 |
+
}
|
| 909 |
+
|
| 910 |
+
.login-type-switch {
|
| 911 |
+
text-align: center;
|
| 912 |
+
margin-top: 12px;
|
| 913 |
+
padding-top: 12px;
|
| 914 |
+
border-top: 1px solid var(--border);
|
| 915 |
+
}
|
| 916 |
+
|
| 917 |
+
.login-type-switch a {
|
| 918 |
+
color: var(--primary);
|
| 919 |
+
text-decoration: none;
|
| 920 |
+
font-weight: 600;
|
| 921 |
+
cursor: pointer;
|
| 922 |
+
font-size: 0.85em;
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
.loading-overlay {
|
| 926 |
+
position: fixed;
|
| 927 |
+
top: 0;
|
| 928 |
+
left: 0;
|
| 929 |
+
width: 100%;
|
| 930 |
+
height: 100%;
|
| 931 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 932 |
+
display: flex;
|
| 933 |
+
flex-direction: column;
|
| 934 |
+
align-items: center;
|
| 935 |
+
justify-content: center;
|
| 936 |
+
z-index: 99999;
|
| 937 |
+
}
|
| 938 |
+
|
| 939 |
+
.loading-overlay.hide {
|
| 940 |
+
display: none !important;
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
.loading-spinner-large {
|
| 944 |
+
width: 50px;
|
| 945 |
+
height: 50px;
|
| 946 |
+
border: 4px solid rgba(255,255,255,.2);
|
| 947 |
+
border-radius: 50%;
|
| 948 |
+
border-top-color: #fff;
|
| 949 |
+
animation: spin 1s linear infinite !important;
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
@keyframes spin {
|
| 953 |
+
to { transform: rotate(360deg); }
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
+
.loading-text {
|
| 957 |
+
color: white;
|
| 958 |
+
margin-top: 15px;
|
| 959 |
+
font-size: 1em;
|
| 960 |
+
font-weight: 600;
|
| 961 |
+
}
|
| 962 |
+
|
| 963 |
+
.loading-spinner {
|
| 964 |
+
text-align: center;
|
| 965 |
+
padding: 40px;
|
| 966 |
+
color: var(--primary);
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
.badge {
|
| 970 |
+
padding: 5px 12px;
|
| 971 |
+
border-radius: 15px;
|
| 972 |
+
font-size: 0.75em;
|
| 973 |
+
font-weight: 700;
|
| 974 |
+
display: inline-block;
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
.badge-success {
|
| 978 |
+
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
| 979 |
+
color: #065f46;
|
| 980 |
+
border: 1px solid #6ee7b7;
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
.badge-danger {
|
| 984 |
+
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
|
| 985 |
+
color: #991b1b;
|
| 986 |
+
border: 1px solid #fca5a5;
|
| 987 |
+
}
|
| 988 |
+
|
| 989 |
+
.empty-state {
|
| 990 |
+
text-align: center;
|
| 991 |
+
padding: 60px 30px;
|
| 992 |
+
color: #94a3b8;
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
.empty-state-icon {
|
| 996 |
+
font-size: 60px;
|
| 997 |
+
margin-bottom: 15px;
|
| 998 |
+
opacity: 0.5;
|
| 999 |
+
}
|
| 1000 |
+
|
| 1001 |
+
.empty-state-text {
|
| 1002 |
+
font-size: 1em;
|
| 1003 |
+
margin-bottom: 8px;
|
| 1004 |
+
font-weight: 600;
|
| 1005 |
+
}
|
| 1006 |
+
|
| 1007 |
+
.search-box {
|
| 1008 |
+
position: relative;
|
| 1009 |
+
}
|
| 1010 |
+
|
| 1011 |
+
.search-box input {
|
| 1012 |
+
width: 100%;
|
| 1013 |
+
padding: 12px 15px 12px 40px;
|
| 1014 |
+
border: 2px solid var(--border);
|
| 1015 |
+
border-radius: 12px;
|
| 1016 |
+
font-size: 0.9em;
|
| 1017 |
+
}
|
| 1018 |
+
|
| 1019 |
+
.search-box input:focus {
|
| 1020 |
+
border-color: var(--primary);
|
| 1021 |
+
}
|
| 1022 |
+
|
| 1023 |
+
.search-box::before {
|
| 1024 |
+
content: '🔍';
|
| 1025 |
+
position: absolute;
|
| 1026 |
+
left: 12px;
|
| 1027 |
+
top: 50%;
|
| 1028 |
+
transform: translateY(-50%);
|
| 1029 |
+
font-size: 1.1em;
|
| 1030 |
+
opacity: 0.5;
|
| 1031 |
+
}
|
| 1032 |
+
|
| 1033 |
+
.skeleton {
|
| 1034 |
+
background: #e0e0e0;
|
| 1035 |
+
border-radius: 8px;
|
| 1036 |
+
}
|
| 1037 |
+
|
| 1038 |
+
.skeleton-card {
|
| 1039 |
+
height: 180px;
|
| 1040 |
+
}
|
| 1041 |
+
|
| 1042 |
+
@media (max-width: 768px) {
|
| 1043 |
+
.app-container {
|
| 1044 |
+
margin: 10px;
|
| 1045 |
+
border-radius: 15px;
|
| 1046 |
+
}
|
| 1047 |
+
|
| 1048 |
+
.app-body {
|
| 1049 |
+
padding: 15px 20px;
|
| 1050 |
+
}
|
| 1051 |
+
|
| 1052 |
+
.header {
|
| 1053 |
+
padding: 15px 20px;
|
| 1054 |
+
}
|
| 1055 |
+
|
| 1056 |
+
.header h1 {
|
| 1057 |
+
font-size: 1.5em;
|
| 1058 |
+
}
|
| 1059 |
+
|
| 1060 |
+
.header-top {
|
| 1061 |
+
flex-direction: column;
|
| 1062 |
+
}
|
| 1063 |
+
|
| 1064 |
+
.status-bar {
|
| 1065 |
+
flex-direction: column;
|
| 1066 |
+
gap: 10px;
|
| 1067 |
+
}
|
| 1068 |
+
|
| 1069 |
+
.status-item {
|
| 1070 |
+
width: 100%;
|
| 1071 |
+
justify-content: space-between;
|
| 1072 |
+
}
|
| 1073 |
+
|
| 1074 |
+
.tab-button {
|
| 1075 |
+
min-width: 100px;
|
| 1076 |
+
padding: 12px 14px;
|
| 1077 |
+
}
|
| 1078 |
+
|
| 1079 |
+
.channel-grid {
|
| 1080 |
+
grid-template-columns: 1fr;
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
.epg-controls {
|
| 1084 |
+
grid-template-columns: 1fr;
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
.player-controls {
|
| 1088 |
+
grid-template-columns: 1fr;
|
| 1089 |
+
}
|
| 1090 |
+
|
| 1091 |
+
.stream-info-panel {
|
| 1092 |
+
grid-template-columns: 1fr;
|
| 1093 |
+
}
|
| 1094 |
+
|
| 1095 |
+
.cache-grid {
|
| 1096 |
+
grid-template-columns: 1fr;
|
| 1097 |
+
}
|
| 1098 |
+
|
| 1099 |
+
.button-group {
|
| 1100 |
+
flex-direction: column;
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
.epg-video-container {
|
| 1104 |
+
max-height: 95vh;
|
| 1105 |
+
}
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
::-webkit-scrollbar {
|
| 1109 |
+
width: 8px;
|
| 1110 |
+
height: 8px;
|
| 1111 |
+
}
|
| 1112 |
+
|
| 1113 |
+
::-webkit-scrollbar-track {
|
| 1114 |
+
background: #f1f5f9;
|
| 1115 |
+
}
|
| 1116 |
+
|
| 1117 |
+
::-webkit-scrollbar-thumb {
|
| 1118 |
+
background: #cbd5e1;
|
| 1119 |
+
border-radius: 4px;
|
| 1120 |
+
}
|
| 1121 |
+
|
| 1122 |
+
::-webkit-scrollbar-thumb:hover {
|
| 1123 |
+
background: #94a3b8;
|
| 1124 |
+
}
|
| 1125 |
+
|
| 1126 |
+
.hidden {
|
| 1127 |
+
display: none !important;
|
| 1128 |
+
}
|
| 1129 |
+
|
| 1130 |
+
.text-center {
|
| 1131 |
+
text-align: center;
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
.admin-only {
|
| 1135 |
+
display: none;
|
| 1136 |
+
}
|
| 1137 |
+
|
| 1138 |
+
.channel-card,
|
| 1139 |
+
.epg-item,
|
| 1140 |
+
.cache-card {
|
| 1141 |
+
transform: translateZ(0);
|
| 1142 |
+
backface-visibility: hidden;
|
| 1143 |
+
}
|
| 1144 |
+
|
| 1145 |
+
@media print {
|
| 1146 |
+
body {
|
| 1147 |
+
background: white;
|
| 1148 |
+
}
|
| 1149 |
+
|
| 1150 |
+
.header,
|
| 1151 |
+
.tabs,
|
| 1152 |
+
.btn,
|
| 1153 |
+
.footer,
|
| 1154 |
+
.notification {
|
| 1155 |
+
display: none;
|
| 1156 |
+
}
|
| 1157 |
+
}
|
static/index.html
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Media Gateway</title>
|
| 7 |
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><defs><linearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'><stop offset='0%25' style='stop-color:%23667eea'/><stop offset='100%25' style='stop-color:%23764ba2'/></linearGradient></defs><rect width='100' height='100' rx='20' fill='url(%23g)'/><rect x='20' y='30' width='60' height='40' rx='5' fill='white' opacity='0.3'/><rect x='25' y='35' width='50' height='30' rx='3' fill='%23fff'/><circle cx='50' cy='80' r='4' fill='%23fff'/></svg>">
|
| 8 |
+
<link rel="stylesheet" href="/static/css/style.css">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<div class="login-overlay" id="loginOverlay">
|
| 14 |
+
<div class="login-box">
|
| 15 |
+
<div class="login-box-header">
|
| 16 |
+
<h1>🎬 Media Gateway</h1>
|
| 17 |
+
<p id="loginSubtitle">欢迎进入</p>
|
| 18 |
+
</div>
|
| 19 |
+
<div class="login-box-body">
|
| 20 |
+
<div id="loginError" class="login-error"></div>
|
| 21 |
+
<div class="login-info">
|
| 22 |
+
<strong>💡 登录说明</strong><br>
|
| 23 |
+
请输入用户名和密码<br>
|
| 24 |
+
</div>
|
| 25 |
+
<form id="loginForm">
|
| 26 |
+
<div class="login-form-group">
|
| 27 |
+
<label for="usernameInput">👤 用户名</label>
|
| 28 |
+
<input
|
| 29 |
+
type="text"
|
| 30 |
+
id="usernameInput"
|
| 31 |
+
placeholder="输入您的用户名"
|
| 32 |
+
autocomplete="username"
|
| 33 |
+
required
|
| 34 |
+
>
|
| 35 |
+
</div>
|
| 36 |
+
<div class="login-form-group">
|
| 37 |
+
<label for="passwordInput">🔒 密码</label>
|
| 38 |
+
<div class="password-toggle-login">
|
| 39 |
+
<input
|
| 40 |
+
type="password"
|
| 41 |
+
id="passwordInput"
|
| 42 |
+
placeholder="输入您的密码"
|
| 43 |
+
autocomplete="current-password"
|
| 44 |
+
required
|
| 45 |
+
>
|
| 46 |
+
<button type="button" class="password-toggle-btn-login" id="togglePasswordBtn">
|
| 47 |
+
👁️
|
| 48 |
+
</button>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
<button type="submit" class="login-btn btn-glow" id="loginBtn">
|
| 52 |
+
🚀 立即登录
|
| 53 |
+
</button>
|
| 54 |
+
</form>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<div class="app-container" id="mainContainer">
|
| 60 |
+
<header class="header">
|
| 61 |
+
<div class="header-top">
|
| 62 |
+
<div>
|
| 63 |
+
<h1>🎬 Media Gateway</h1>
|
| 64 |
+
<p class="subtitle">v2.0</p>
|
| 65 |
+
</div>
|
| 66 |
+
<button class="btn-logout" id="logoutBtn">
|
| 67 |
+
🚪 退出登录
|
| 68 |
+
</button>
|
| 69 |
+
</div>
|
| 70 |
+
<div class="status-bar">
|
| 71 |
+
<div class="status-item">
|
| 72 |
+
<span class="status-label">🟢 状态</span>
|
| 73 |
+
<span id="apiStatus" class="status-value loading">检查中...</span>
|
| 74 |
+
</div>
|
| 75 |
+
<div class="status-item">
|
| 76 |
+
<span class="status-label">⚡ 响应</span>
|
| 77 |
+
<span id="responseTime" class="status-value">-</span>
|
| 78 |
+
</div>
|
| 79 |
+
<div class="status-item">
|
| 80 |
+
<span class="status-label">👤 用户</span>
|
| 81 |
+
<span id="currentUser" class="status-value">-</span>
|
| 82 |
+
<span id="userTypeBadge" class="user-type-badge"></span>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
</header>
|
| 86 |
+
|
| 87 |
+
<nav class="tabs">
|
| 88 |
+
<a href="/channels" class="tab-button" data-page="channels">
|
| 89 |
+
📋 频道列表
|
| 90 |
+
</a>
|
| 91 |
+
<a href="/player" class="tab-button" data-page="player">
|
| 92 |
+
▶️ 直播播放
|
| 93 |
+
</a>
|
| 94 |
+
<a href="/epg" class="tab-button" data-page="epg">
|
| 95 |
+
📅 节目表
|
| 96 |
+
</a>
|
| 97 |
+
<a href="/cache" class="tab-button admin-only" data-page="cache">
|
| 98 |
+
🗄️ 缓存管理
|
| 99 |
+
</a>
|
| 100 |
+
<a href="/api-test" class="tab-button admin-only" data-page="api-test">
|
| 101 |
+
🔧 API测试
|
| 102 |
+
</a>
|
| 103 |
+
</nav>
|
| 104 |
+
|
| 105 |
+
<main class="app-body" id="appBody">
|
| 106 |
+
<div id="pageContent">
|
| 107 |
+
<div class="loading-spinner">
|
| 108 |
+
<div class="loading-spinner-large"></div>
|
| 109 |
+
<p style="margin-top: 15px; color: var(--primary); font-weight: 600;">加载中...</p>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
</main>
|
| 113 |
+
|
| 114 |
+
<footer class="footer">
|
| 115 |
+
<p>
|
| 116 |
+
<strong>Media Gateway v2.0</strong>
|
| 117 |
+
</p>
|
| 118 |
+
</footer>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<script src="/static/js/common.js"></script>
|
| 122 |
+
<script src="/static/js/downloader.js"></script>
|
| 123 |
+
<script src="/static/js/user-data-sync.js"></script>
|
| 124 |
+
<script src="/static/js/auth.js"></script>
|
| 125 |
+
<script src="/static/js/router.js"></script>
|
| 126 |
+
</body>
|
| 127 |
+
</html>
|
static/js/auth.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(function() {
|
| 2 |
+
'use strict';
|
| 3 |
+
|
| 4 |
+
const API = window.location.origin;
|
| 5 |
+
let userBadges = {};
|
| 6 |
+
|
| 7 |
+
function isLoggedIn() {
|
| 8 |
+
return sessionStorage.getItem('logged_in') === 'true';
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function isAdmin() {
|
| 12 |
+
return sessionStorage.getItem('is_admin') === 'true';
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function saveLogin(username, isAdminUser, badge = null) {
|
| 16 |
+
sessionStorage.setItem('logged_in', 'true');
|
| 17 |
+
sessionStorage.setItem('username', username);
|
| 18 |
+
sessionStorage.setItem('is_admin', isAdminUser ? 'true' : 'false');
|
| 19 |
+
sessionStorage.setItem('user_badge', badge || '');
|
| 20 |
+
sessionStorage.setItem('login_time', Date.now().toString());
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function clearLogin() {
|
| 24 |
+
sessionStorage.clear();
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
async function sha256(text) {
|
| 28 |
+
try {
|
| 29 |
+
const buf = new TextEncoder().encode(text);
|
| 30 |
+
const hash = await crypto.subtle.digest('SHA-256', buf);
|
| 31 |
+
const arr = Array.from(new Uint8Array(hash));
|
| 32 |
+
return arr.map(b => b.toString(16).padStart(2, '0')).join('');
|
| 33 |
+
} catch (error) {
|
| 34 |
+
throw error;
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function showError(msg) {
|
| 39 |
+
const el = document.getElementById('loginError');
|
| 40 |
+
if (el) {
|
| 41 |
+
el.textContent = '❌ ' + msg;
|
| 42 |
+
el.classList.add('show');
|
| 43 |
+
setTimeout(() => el.classList.remove('show'), 5000);
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function showSuccess(msg) {
|
| 48 |
+
const el = document.getElementById('loginError');
|
| 49 |
+
if (el) {
|
| 50 |
+
el.innerHTML = msg; // ✅ 使用 innerHTML 支持 HTML
|
| 51 |
+
el.style.background = 'linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%)';
|
| 52 |
+
el.style.color = '#065f46';
|
| 53 |
+
el.style.borderColor = '#6ee7b7';
|
| 54 |
+
el.classList.add('show');
|
| 55 |
+
setTimeout(() => {
|
| 56 |
+
el.classList.remove('show');
|
| 57 |
+
el.style.background = '';
|
| 58 |
+
el.style.color = '';
|
| 59 |
+
el.style.borderColor = '';
|
| 60 |
+
}, 2000);
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
async function loadBadges() {
|
| 65 |
+
try {
|
| 66 |
+
const res = await fetch(`${API}/api/badges`);
|
| 67 |
+
const data = await res.json();
|
| 68 |
+
if (data.success) {
|
| 69 |
+
userBadges = data.badges;
|
| 70 |
+
}
|
| 71 |
+
} catch (e) {
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function updateAdminUI() {
|
| 76 |
+
const isAdminUser = isAdmin();
|
| 77 |
+
|
| 78 |
+
const adminElements = document.querySelectorAll('.admin-only');
|
| 79 |
+
|
| 80 |
+
adminElements.forEach(el => {
|
| 81 |
+
if (isAdminUser) {
|
| 82 |
+
el.style.display = '';
|
| 83 |
+
el.style.visibility = 'visible';
|
| 84 |
+
el.style.opacity = '1';
|
| 85 |
+
el.classList.remove('hidden');
|
| 86 |
+
|
| 87 |
+
if (el.classList.contains('tab-button')) {
|
| 88 |
+
el.style.display = 'block';
|
| 89 |
+
}
|
| 90 |
+
} else {
|
| 91 |
+
el.style.display = 'none';
|
| 92 |
+
el.style.visibility = 'hidden';
|
| 93 |
+
el.style.opacity = '0';
|
| 94 |
+
el.classList.add('hidden');
|
| 95 |
+
}
|
| 96 |
+
});
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function showMainPage() {
|
| 100 |
+
const login = document.getElementById('loginOverlay');
|
| 101 |
+
const main = document.getElementById('mainContainer');
|
| 102 |
+
|
| 103 |
+
if (!login || !main) {
|
| 104 |
+
return;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
login.classList.add('hide');
|
| 108 |
+
main.style.display = 'flex';
|
| 109 |
+
|
| 110 |
+
const username = sessionStorage.getItem('username') || '未知';
|
| 111 |
+
const isAdminUser = isAdmin();
|
| 112 |
+
const userBadge = sessionStorage.getItem('user_badge') || '';
|
| 113 |
+
|
| 114 |
+
const userEl = document.getElementById('currentUser');
|
| 115 |
+
if (userEl) {
|
| 116 |
+
userEl.textContent = username;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const badge = document.getElementById('userTypeBadge');
|
| 120 |
+
if (badge) {
|
| 121 |
+
badge.innerHTML = '';
|
| 122 |
+
badge.className = 'user-type-badge';
|
| 123 |
+
|
| 124 |
+
if (isAdminUser) {
|
| 125 |
+
badge.innerHTML = '👑 管理员';
|
| 126 |
+
badge.className = 'user-type-badge admin';
|
| 127 |
+
} else if (userBadge && userBadges[userBadge]) {
|
| 128 |
+
const badgeInfo = userBadges[userBadge];
|
| 129 |
+
|
| 130 |
+
badge.innerHTML = `
|
| 131 |
+
<span style="margin-right: 5px;">${badgeInfo.icon}</span>
|
| 132 |
+
<span>${badgeInfo.name}</span>
|
| 133 |
+
`;
|
| 134 |
+
badge.style.background = badgeInfo.gradient;
|
| 135 |
+
badge.style.color = badgeInfo.color;
|
| 136 |
+
badge.style.border = `2px solid ${badgeInfo.border}`;
|
| 137 |
+
badge.style.boxShadow = `0 2px 8px ${badgeInfo.glow}`;
|
| 138 |
+
badge.style.padding = '6px 14px';
|
| 139 |
+
badge.style.borderRadius = '20px';
|
| 140 |
+
badge.style.fontSize = '0.85em';
|
| 141 |
+
badge.style.fontWeight = '700';
|
| 142 |
+
badge.style.display = 'inline-flex';
|
| 143 |
+
badge.style.alignItems = 'center';
|
| 144 |
+
} else {
|
| 145 |
+
badge.innerHTML = '👤 普通用户';
|
| 146 |
+
badge.className = 'user-type-badge user';
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
updateAdminUI();
|
| 151 |
+
|
| 152 |
+
setTimeout(() => checkAPI(), 100);
|
| 153 |
+
|
| 154 |
+
setTimeout(() => {
|
| 155 |
+
window.dispatchEvent(new Event('user-logged-in'));
|
| 156 |
+
}, 200);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
function showLoginPage() {
|
| 160 |
+
const login = document.getElementById('loginOverlay');
|
| 161 |
+
const main = document.getElementById('mainContainer');
|
| 162 |
+
|
| 163 |
+
if (login) login.classList.remove('hide');
|
| 164 |
+
if (main) main.style.display = 'none';
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
async function checkAPI() {
|
| 168 |
+
try {
|
| 169 |
+
const start = Date.now();
|
| 170 |
+
const res = await fetch('/health');
|
| 171 |
+
const time = Date.now() - start;
|
| 172 |
+
const data = await res.json();
|
| 173 |
+
|
| 174 |
+
const status = document.getElementById('apiStatus');
|
| 175 |
+
const timeEl = document.getElementById('responseTime');
|
| 176 |
+
|
| 177 |
+
if (status) {
|
| 178 |
+
if (data.status === 'running') {
|
| 179 |
+
status.textContent = '在线';
|
| 180 |
+
status.className = 'status-value online';
|
| 181 |
+
} else {
|
| 182 |
+
status.textContent = '异常';
|
| 183 |
+
status.className = 'status-value loading';
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
if (timeEl) timeEl.textContent = time + 'ms';
|
| 188 |
+
} catch (e) {
|
| 189 |
+
const status = document.getElementById('apiStatus');
|
| 190 |
+
if (status) {
|
| 191 |
+
status.textContent = '离线';
|
| 192 |
+
status.className = 'status-value offline';
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
async function handleLogin(e) {
|
| 198 |
+
e.preventDefault();
|
| 199 |
+
|
| 200 |
+
const user = document.getElementById('usernameInput');
|
| 201 |
+
const pass = document.getElementById('passwordInput');
|
| 202 |
+
const btn = document.getElementById('loginBtn');
|
| 203 |
+
|
| 204 |
+
if (!user || !pass || !btn) {
|
| 205 |
+
showError('页面元素错误');
|
| 206 |
+
return;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
const username = user.value.trim();
|
| 210 |
+
const password = pass.value;
|
| 211 |
+
|
| 212 |
+
if (!username || !password) {
|
| 213 |
+
showError('请输入用户名和密码');
|
| 214 |
+
return;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
btn.disabled = true;
|
| 218 |
+
btn.innerHTML = '<div class="loading-spinner-large" style="width: 16px; height: 16px; border-width: 3px; margin-right: 8px; display: inline-block; vertical-align: middle;"></div> 验证中...';
|
| 219 |
+
|
| 220 |
+
try {
|
| 221 |
+
const hash = await sha256(password);
|
| 222 |
+
|
| 223 |
+
const res = await fetch('/api/verify-password', {
|
| 224 |
+
method: 'POST',
|
| 225 |
+
headers: { 'Content-Type': 'application/json' },
|
| 226 |
+
body: JSON.stringify({ username, password_hash: hash })
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
const data = await res.json();
|
| 230 |
+
|
| 231 |
+
if (data.success) {
|
| 232 |
+
const isAdminUser = data.user?.is_admin || false;
|
| 233 |
+
const userBadge = data.user?.badge || null;
|
| 234 |
+
|
| 235 |
+
// ✅ 保存用户数据到 sessionStorage
|
| 236 |
+
if (data.user_data) {
|
| 237 |
+
const userDataKey = `user_data_${username}`;
|
| 238 |
+
sessionStorage.setItem(userDataKey, JSON.stringify(data.user_data));
|
| 239 |
+
console.log('✅ 用户数据已保存到 sessionStorage');
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
// 生成欢迎语
|
| 243 |
+
let welcomeMsg = '✅ 登录成功!';
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
if (isAdminUser) {
|
| 247 |
+
welcomeMsg += '<br><div style="margin-top: 10px; display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%); color: white; border-radius: 12px; font-weight: 700;">👑 欢迎管理员</div>';
|
| 248 |
+
} else if (userBadge && userBadges[userBadge]) {
|
| 249 |
+
const badgeInfo = userBadges[userBadge];
|
| 250 |
+
welcomeMsg += `<br><div style="margin-top: 10px; display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; background: ${badgeInfo.gradient}; color: ${badgeInfo.color}; border: 2px solid ${badgeInfo.border}; box-shadow: 0 2px 8px ${badgeInfo.glow}; border-radius: 12px; font-weight: 700;"><span>${badgeInfo.icon}</span><span>欢迎 ${badgeInfo.name}</span></div>`;
|
| 251 |
+
} else {
|
| 252 |
+
welcomeMsg += '<br><div style="margin-top: 10px; color: #065f46; font-weight: 600;">👤 欢迎普通用户</div>';
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
showSuccess(welcomeMsg);
|
| 256 |
+
|
| 257 |
+
saveLogin(username, isAdminUser, userBadge);
|
| 258 |
+
|
| 259 |
+
await loadBadges();
|
| 260 |
+
|
| 261 |
+
setTimeout(() => {
|
| 262 |
+
showMainPage();
|
| 263 |
+
}, 1500);
|
| 264 |
+
|
| 265 |
+
} else {
|
| 266 |
+
showError('用户名或密码错误');
|
| 267 |
+
btn.disabled = false;
|
| 268 |
+
btn.textContent = '🚀 立即登录';
|
| 269 |
+
pass.value = '';
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
} catch (err) {
|
| 273 |
+
showError('登录失败: ' + err.message);
|
| 274 |
+
btn.disabled = false;
|
| 275 |
+
btn.textContent = '🚀 立即登录';
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
function togglePassword() {
|
| 280 |
+
const input = document.getElementById('passwordInput');
|
| 281 |
+
const btn = document.getElementById('togglePasswordBtn');
|
| 282 |
+
|
| 283 |
+
if (!input || !btn) return;
|
| 284 |
+
|
| 285 |
+
if (input.type === 'password') {
|
| 286 |
+
input.type = 'text';
|
| 287 |
+
btn.textContent = '🙈';
|
| 288 |
+
} else {
|
| 289 |
+
input.type = 'password';
|
| 290 |
+
btn.textContent = '👁️';
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
function handleLogout() {
|
| 295 |
+
if (confirm('确定要退出吗?')) {
|
| 296 |
+
clearLogin();
|
| 297 |
+
location.reload();
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
async function init() {
|
| 302 |
+
await loadBadges();
|
| 303 |
+
|
| 304 |
+
if (isLoggedIn()) {
|
| 305 |
+
setTimeout(() => showMainPage(), 100);
|
| 306 |
+
} else {
|
| 307 |
+
showLoginPage();
|
| 308 |
+
setTimeout(() => {
|
| 309 |
+
const user = document.getElementById('usernameInput');
|
| 310 |
+
if (user) user.focus();
|
| 311 |
+
}, 100);
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
const form = document.getElementById('loginForm');
|
| 315 |
+
const toggleBtn = document.getElementById('togglePasswordBtn');
|
| 316 |
+
const logoutBtn = document.getElementById('logoutBtn');
|
| 317 |
+
|
| 318 |
+
if (form) {
|
| 319 |
+
form.addEventListener('submit', handleLogin);
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
if (toggleBtn) {
|
| 323 |
+
toggleBtn.addEventListener('click', togglePassword);
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
if (logoutBtn) {
|
| 327 |
+
logoutBtn.addEventListener('click', handleLogout);
|
| 328 |
+
}
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
window.isAdmin = isAdmin;
|
| 332 |
+
window.updateAdminUI = updateAdminUI;
|
| 333 |
+
|
| 334 |
+
if (document.readyState === 'loading') {
|
| 335 |
+
document.addEventListener('DOMContentLoaded', init);
|
| 336 |
+
} else {
|
| 337 |
+
init();
|
| 338 |
+
}
|
| 339 |
+
})();
|
static/js/common.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(function() {
|
| 2 |
+
'use strict';
|
| 3 |
+
|
| 4 |
+
window.MediaGatewayUtils = {
|
| 5 |
+
formatTime(ts) {
|
| 6 |
+
const d = new Date(ts * 1000);
|
| 7 |
+
return d.toLocaleTimeString('ja-JP', {
|
| 8 |
+
timeZone: 'Asia/Tokyo',
|
| 9 |
+
hour: '2-digit',
|
| 10 |
+
minute: '2-digit',
|
| 11 |
+
hour12: false
|
| 12 |
+
});
|
| 13 |
+
},
|
| 14 |
+
|
| 15 |
+
formatDate(ts) {
|
| 16 |
+
const d = new Date(ts * 1000);
|
| 17 |
+
return d.toLocaleDateString('ja-JP', {
|
| 18 |
+
timeZone: 'Asia/Tokyo',
|
| 19 |
+
year: 'numeric',
|
| 20 |
+
month: '2-digit',
|
| 21 |
+
day: '2-digit'
|
| 22 |
+
}).replace(/\//g, '-');
|
| 23 |
+
},
|
| 24 |
+
|
| 25 |
+
formatDuration(sec) {
|
| 26 |
+
const h = Math.floor(sec / 3600);
|
| 27 |
+
const m = Math.floor((sec % 3600) / 60);
|
| 28 |
+
return h > 0 ? `${h}小时${m}分钟` : `${m}分钟`;
|
| 29 |
+
},
|
| 30 |
+
|
| 31 |
+
formatBytes(b) {
|
| 32 |
+
if (b === 0) return '0 B';
|
| 33 |
+
const k = 1024;
|
| 34 |
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
| 35 |
+
const i = Math.floor(Math.log(b) / Math.log(k));
|
| 36 |
+
return (b / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
|
| 37 |
+
},
|
| 38 |
+
|
| 39 |
+
getJSTDate(d) {
|
| 40 |
+
if (!d) d = new Date();
|
| 41 |
+
const jst = new Date(d.toLocaleString('en-US', { timeZone: 'Asia/Tokyo' }));
|
| 42 |
+
return jst.toISOString().split('T')[0];
|
| 43 |
+
},
|
| 44 |
+
|
| 45 |
+
showNotification(msg, type = 'info') {
|
| 46 |
+
const old = document.querySelector('.notification');
|
| 47 |
+
if (old) old.remove();
|
| 48 |
+
|
| 49 |
+
const n = document.createElement('div');
|
| 50 |
+
n.className = `notification notification-${type}`;
|
| 51 |
+
n.textContent = msg;
|
| 52 |
+
document.body.appendChild(n);
|
| 53 |
+
|
| 54 |
+
setTimeout(() => n.remove(), 3000);
|
| 55 |
+
},
|
| 56 |
+
|
| 57 |
+
async checkAPIStatus() {
|
| 58 |
+
try {
|
| 59 |
+
const start = Date.now();
|
| 60 |
+
const res = await fetch('/health');
|
| 61 |
+
const time = Date.now() - start;
|
| 62 |
+
const data = await res.json();
|
| 63 |
+
|
| 64 |
+
return {
|
| 65 |
+
success: true,
|
| 66 |
+
status: data.status,
|
| 67 |
+
responseTime: time,
|
| 68 |
+
data: data
|
| 69 |
+
};
|
| 70 |
+
} catch (e) {
|
| 71 |
+
return {
|
| 72 |
+
success: false,
|
| 73 |
+
error: e.message
|
| 74 |
+
};
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
};
|
| 78 |
+
})();
|
static/js/downloader.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class HLSDownloader {
|
| 2 |
+
constructor(fragments = [], options = {}) {
|
| 3 |
+
this.fragments = fragments.map((f, i) => ({ ...f, index: i }));
|
| 4 |
+
this.thread = options.thread || 6;
|
| 5 |
+
this.onProgress = options.onProgress || (() => {});
|
| 6 |
+
this.onError = options.onError || (() => {});
|
| 7 |
+
this.onComplete = options.onComplete || (() => {});
|
| 8 |
+
this.onItemComplete = options.onItemComplete || (() => {});
|
| 9 |
+
|
| 10 |
+
this.buffer = [];
|
| 11 |
+
this.downloaded = 0;
|
| 12 |
+
this.totalSize = 0;
|
| 13 |
+
this.isRunning = false;
|
| 14 |
+
this.isStopped = false;
|
| 15 |
+
this.controllers = [];
|
| 16 |
+
this.currentIndex = 0;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
async start() {
|
| 20 |
+
if (this.isRunning) return;
|
| 21 |
+
|
| 22 |
+
this.isRunning = true;
|
| 23 |
+
this.isStopped = false;
|
| 24 |
+
|
| 25 |
+
const workers = [];
|
| 26 |
+
for (let i = 0; i < Math.min(this.thread, this.fragments.length); i++) {
|
| 27 |
+
workers.push(this.downloadWorker());
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
await Promise.all(workers);
|
| 31 |
+
|
| 32 |
+
if (!this.isStopped) {
|
| 33 |
+
this.onComplete(this.buffer);
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
async downloadWorker() {
|
| 38 |
+
while (this.currentIndex < this.fragments.length && !this.isStopped) {
|
| 39 |
+
const index = this.currentIndex++;
|
| 40 |
+
const fragment = this.fragments[index];
|
| 41 |
+
|
| 42 |
+
try {
|
| 43 |
+
await this.downloadFragment(fragment);
|
| 44 |
+
} catch (error) {
|
| 45 |
+
this.onError(fragment, error);
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
async downloadFragment(fragment) {
|
| 51 |
+
const controller = new AbortController();
|
| 52 |
+
this.controllers[fragment.index] = controller;
|
| 53 |
+
|
| 54 |
+
try {
|
| 55 |
+
const response = await fetch(fragment.url, {
|
| 56 |
+
signal: controller.signal
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
if (!response.ok) {
|
| 60 |
+
throw new Error(`HTTP ${response.status}`);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
const reader = response.body.getReader();
|
| 64 |
+
const contentLength = parseInt(response.headers.get('content-length')) || 0;
|
| 65 |
+
let receivedLength = 0;
|
| 66 |
+
const chunks = [];
|
| 67 |
+
|
| 68 |
+
while (true) {
|
| 69 |
+
const { done, value } = await reader.read();
|
| 70 |
+
|
| 71 |
+
if (done) break;
|
| 72 |
+
|
| 73 |
+
chunks.push(value);
|
| 74 |
+
receivedLength += value.length;
|
| 75 |
+
|
| 76 |
+
this.onProgress({
|
| 77 |
+
index: fragment.index,
|
| 78 |
+
current: receivedLength,
|
| 79 |
+
total: contentLength,
|
| 80 |
+
percentage: contentLength ? (receivedLength / contentLength * 100).toFixed(2) : 0
|
| 81 |
+
});
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const buffer = new Uint8Array(receivedLength);
|
| 85 |
+
let position = 0;
|
| 86 |
+
for (const chunk of chunks) {
|
| 87 |
+
buffer.set(chunk, position);
|
| 88 |
+
position += chunk.length;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
this.buffer[fragment.index] = buffer.buffer;
|
| 92 |
+
this.downloaded++;
|
| 93 |
+
this.totalSize += buffer.length;
|
| 94 |
+
|
| 95 |
+
this.onItemComplete(fragment, buffer.buffer);
|
| 96 |
+
|
| 97 |
+
} catch (error) {
|
| 98 |
+
if (error.name !== 'AbortError') {
|
| 99 |
+
throw error;
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
stop() {
|
| 105 |
+
this.isStopped = true;
|
| 106 |
+
this.isRunning = false;
|
| 107 |
+
this.controllers.forEach(controller => {
|
| 108 |
+
if (controller) controller.abort();
|
| 109 |
+
});
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
getProgress() {
|
| 113 |
+
return {
|
| 114 |
+
downloaded: this.downloaded,
|
| 115 |
+
total: this.fragments.length,
|
| 116 |
+
percentage: (this.downloaded / this.fragments.length * 100).toFixed(2),
|
| 117 |
+
size: this.totalSize
|
| 118 |
+
};
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
if (typeof window !== 'undefined') {
|
| 123 |
+
window.HLSDownloader = HLSDownloader;
|
| 124 |
+
}
|
static/js/hls.min.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/js/router.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(function() {
|
| 2 |
+
'use strict';
|
| 3 |
+
|
| 4 |
+
const pages = {
|
| 5 |
+
'channels': { title: '频道列表', subtitle: '浏览所有可用的电视频道', icon: '📋' },
|
| 6 |
+
'player': { title: '直播播放', subtitle: '在线观看电视直播并支持录制', icon: '▶️' },
|
| 7 |
+
'epg': { title: 'EPG 节目表', subtitle: '查看电视节目时间表和回看内容', icon: '📅' },
|
| 8 |
+
'cache': { title: '缓存管理', subtitle: '管理系统缓存和性能优化', icon: '🗄️', adminOnly: true },
|
| 9 |
+
'api-test': { title: 'API 测试', subtitle: '测试后端 API 接口', icon: '🔧', adminOnly: true }
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
let currentPage = null;
|
| 13 |
+
|
| 14 |
+
function isLoggedIn() {
|
| 15 |
+
return sessionStorage.getItem('logged_in') === 'true';
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function isAdmin() {
|
| 19 |
+
if (window.isAdmin && typeof window.isAdmin === 'function') {
|
| 20 |
+
return window.isAdmin();
|
| 21 |
+
}
|
| 22 |
+
return sessionStorage.getItem('is_admin') === 'true';
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function updateAdminTabs() {
|
| 26 |
+
if (window.updateAdminUI && typeof window.updateAdminUI === 'function') {
|
| 27 |
+
window.updateAdminUI();
|
| 28 |
+
} else {
|
| 29 |
+
const isAdminUser = isAdmin();
|
| 30 |
+
const adminElements = document.querySelectorAll('.admin-only');
|
| 31 |
+
|
| 32 |
+
adminElements.forEach(el => {
|
| 33 |
+
if (isAdminUser) {
|
| 34 |
+
el.style.display = '';
|
| 35 |
+
el.style.visibility = 'visible';
|
| 36 |
+
el.style.opacity = '1';
|
| 37 |
+
el.classList.remove('hidden');
|
| 38 |
+
|
| 39 |
+
if (el.classList.contains('tab-button')) {
|
| 40 |
+
el.style.display = 'block';
|
| 41 |
+
}
|
| 42 |
+
} else {
|
| 43 |
+
el.style.display = 'none';
|
| 44 |
+
el.style.visibility = 'hidden';
|
| 45 |
+
el.style.opacity = '0';
|
| 46 |
+
el.classList.add('hidden');
|
| 47 |
+
}
|
| 48 |
+
});
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function executeScripts(container) {
|
| 53 |
+
const scripts = container.querySelectorAll('script');
|
| 54 |
+
scripts.forEach(old => {
|
| 55 |
+
const script = document.createElement('script');
|
| 56 |
+
Array.from(old.attributes).forEach(attr => {
|
| 57 |
+
script.setAttribute(attr.name, attr.value);
|
| 58 |
+
});
|
| 59 |
+
script.textContent = old.textContent;
|
| 60 |
+
old.parentNode.replaceChild(script, old);
|
| 61 |
+
});
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
async function loadPage(page, addHistory = true) {
|
| 65 |
+
const config = pages[page];
|
| 66 |
+
if (!config) return;
|
| 67 |
+
|
| 68 |
+
if (config.adminOnly && !isAdmin()) {
|
| 69 |
+
if (window.MediaGatewayUtils) {
|
| 70 |
+
window.MediaGatewayUtils.showNotification('此功能仅限管理员使用', 'error');
|
| 71 |
+
}
|
| 72 |
+
return;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
currentPage = page;
|
| 76 |
+
|
| 77 |
+
if (addHistory) {
|
| 78 |
+
history.pushState({ page }, '', '/' + page);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
const icon = document.getElementById('pageIcon');
|
| 82 |
+
const title = document.getElementById('pageTitle');
|
| 83 |
+
const subtitle = document.getElementById('pageSubtitle');
|
| 84 |
+
|
| 85 |
+
if (icon) icon.textContent = config.icon;
|
| 86 |
+
if (title) title.textContent = config.title;
|
| 87 |
+
if (subtitle) subtitle.textContent = config.subtitle;
|
| 88 |
+
|
| 89 |
+
document.title = config.title + ' - Media Gateway';
|
| 90 |
+
|
| 91 |
+
document.querySelectorAll('.tab-button').forEach(btn => {
|
| 92 |
+
btn.classList.toggle('active', btn.dataset.page === page);
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
const content = document.getElementById('pageContent');
|
| 96 |
+
if (!content) return;
|
| 97 |
+
|
| 98 |
+
content.innerHTML = '<div class="loading-spinner"><div class="loading-spinner-large"></div><p style="margin-top: 15px; color: var(--primary); font-weight: 600;">加载中...</p></div>';
|
| 99 |
+
|
| 100 |
+
try {
|
| 101 |
+
const url = `/static/templates/${page}.html`;
|
| 102 |
+
const res = await fetch(url);
|
| 103 |
+
|
| 104 |
+
if (!res.ok) {
|
| 105 |
+
throw new Error(`HTTP ${res.status}`);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const html = await res.text();
|
| 109 |
+
content.innerHTML = html;
|
| 110 |
+
executeScripts(content);
|
| 111 |
+
|
| 112 |
+
} catch (err) {
|
| 113 |
+
content.innerHTML = `
|
| 114 |
+
<div style="text-align: center; padding: 60px 30px; color: var(--danger);">
|
| 115 |
+
<div style="font-size: 60px; margin-bottom: 20px;">❌</div>
|
| 116 |
+
<h3 style="margin-bottom: 10px;">加载失败</h3>
|
| 117 |
+
<p style="color: #64748b;">${err.message}</p>
|
| 118 |
+
<button class="btn btn-primary" onclick="location.reload()" style="margin-top: 20px;">
|
| 119 |
+
🔄 重新加载
|
| 120 |
+
</button>
|
| 121 |
+
</div>
|
| 122 |
+
`;
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function navigate(page) {
|
| 127 |
+
if (!isLoggedIn()) return;
|
| 128 |
+
loadPage(page, true);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
function init() {
|
| 132 |
+
if (!isLoggedIn()) return;
|
| 133 |
+
|
| 134 |
+
updateAdminTabs();
|
| 135 |
+
|
| 136 |
+
document.addEventListener('click', (e) => {
|
| 137 |
+
const link = e.target.closest('a.tab-button');
|
| 138 |
+
if (link) {
|
| 139 |
+
e.preventDefault();
|
| 140 |
+
const page = link.dataset.page;
|
| 141 |
+
|
| 142 |
+
const config = pages[page];
|
| 143 |
+
if (config && config.adminOnly && !isAdmin()) {
|
| 144 |
+
if (window.MediaGatewayUtils) {
|
| 145 |
+
window.MediaGatewayUtils.showNotification('此功能仅限管理员使用', 'error');
|
| 146 |
+
}
|
| 147 |
+
return;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
navigate(page);
|
| 151 |
+
}
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
window.addEventListener('popstate', (e) => {
|
| 155 |
+
if (e.state && e.state.page) {
|
| 156 |
+
loadPage(e.state.page, false);
|
| 157 |
+
}
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
const path = location.pathname.substring(1) || 'channels';
|
| 161 |
+
loadPage(path, false);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
window.addEventListener('user-logged-in', () => {
|
| 165 |
+
setTimeout(() => {
|
| 166 |
+
init();
|
| 167 |
+
updateAdminTabs();
|
| 168 |
+
}, 100);
|
| 169 |
+
});
|
| 170 |
+
|
| 171 |
+
if (document.readyState === 'loading') {
|
| 172 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 173 |
+
if (isLoggedIn()) {
|
| 174 |
+
init();
|
| 175 |
+
}
|
| 176 |
+
});
|
| 177 |
+
} else {
|
| 178 |
+
if (isLoggedIn()) {
|
| 179 |
+
init();
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
// ✅ 优化后的 navigateToPlayer 函数
|
| 184 |
+
window.navigateToPlayer = function(no, autoPlay = false) {
|
| 185 |
+
console.log('🎬 navigateToPlayer 调用', { no, autoPlay });
|
| 186 |
+
|
| 187 |
+
// ✅ 存储参数到 sessionStorage
|
| 188 |
+
sessionStorage.setItem('player_channel', no);
|
| 189 |
+
sessionStorage.setItem('player_autoplay', autoPlay ? 'true' : 'false');
|
| 190 |
+
|
| 191 |
+
console.log('💾 参数已保存到 sessionStorage:', {
|
| 192 |
+
player_channel: no,
|
| 193 |
+
player_autoplay: autoPlay
|
| 194 |
+
});
|
| 195 |
+
|
| 196 |
+
// 跳转到播放器页面
|
| 197 |
+
navigate('player');
|
| 198 |
+
};
|
| 199 |
+
|
| 200 |
+
window.navigateToEPG = function(id, date) {
|
| 201 |
+
navigate('epg');
|
| 202 |
+
setTimeout(() => {
|
| 203 |
+
if (window.setEPGChannel) {
|
| 204 |
+
window.setEPGChannel(id, date);
|
| 205 |
+
}
|
| 206 |
+
}, 500);
|
| 207 |
+
};
|
| 208 |
+
|
| 209 |
+
window.updateAdminTabs = updateAdminTabs;
|
| 210 |
+
window.getCurrentPage = function() {
|
| 211 |
+
return currentPage;
|
| 212 |
+
};
|
| 213 |
+
window.navigateTo = navigate;
|
| 214 |
+
|
| 215 |
+
})();
|
static/js/user-data-sync.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(function() {
|
| 2 |
+
'use strict';
|
| 3 |
+
class UserDataSync {
|
| 4 |
+
constructor() {
|
| 5 |
+
this.username = null;
|
| 6 |
+
this.data = {
|
| 7 |
+
favorite_channels: [],
|
| 8 |
+
download_concurrency: 16,
|
| 9 |
+
batch_download_concurrency: 3,
|
| 10 |
+
fab_position: { bottom: 30, right: 30 },
|
| 11 |
+
playback_history: [],
|
| 12 |
+
program_reminders: []
|
| 13 |
+
};
|
| 14 |
+
this.isInitialized = false;
|
| 15 |
+
this.pendingUpdates = {};
|
| 16 |
+
this.saveTimer = null;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* 初始化用户数据
|
| 21 |
+
*/
|
| 22 |
+
init(username) {
|
| 23 |
+
if (!username) {
|
| 24 |
+
console.warn('⚠️ UserDataSync: 未提供用户名');
|
| 25 |
+
return false;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
this.username = username;
|
| 29 |
+
|
| 30 |
+
// ✅ 从 sessionStorage 加载(登录时后端已写入)
|
| 31 |
+
const userDataKey = `user_data_${username}`;
|
| 32 |
+
const savedData = sessionStorage.getItem(userDataKey);
|
| 33 |
+
|
| 34 |
+
if (savedData) {
|
| 35 |
+
try {
|
| 36 |
+
const parsed = JSON.parse(savedData);
|
| 37 |
+
this.data = { ...this.data, ...parsed };
|
| 38 |
+
console.log('✅ 用户数据已加载:', Object.keys(this.data));
|
| 39 |
+
} catch (e) {
|
| 40 |
+
console.error('❌ 解析用户数据失败:', e);
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
this.isInitialized = true;
|
| 45 |
+
return true;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* 标记数据已修改(防抖保存)
|
| 50 |
+
*/
|
| 51 |
+
markChanged(key) {
|
| 52 |
+
if (!this.isInitialized) return;
|
| 53 |
+
|
| 54 |
+
this.pendingUpdates[key] = this.data[key];
|
| 55 |
+
|
| 56 |
+
// 防抖:1秒后保存
|
| 57 |
+
if (this.saveTimer) {
|
| 58 |
+
clearTimeout(this.saveTimer);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
this.saveTimer = setTimeout(() => {
|
| 62 |
+
this.save();
|
| 63 |
+
}, 1000);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/**
|
| 67 |
+
* 立即保存到后端
|
| 68 |
+
*/
|
| 69 |
+
async save(force = false) {
|
| 70 |
+
if (!this.isInitialized || !this.username) return false;
|
| 71 |
+
if (!force && Object.keys(this.pendingUpdates).length === 0) return true;
|
| 72 |
+
|
| 73 |
+
const updates = force ? this.data : this.pendingUpdates;
|
| 74 |
+
|
| 75 |
+
try {
|
| 76 |
+
// ✅ 保存到 sessionStorage(同步)
|
| 77 |
+
const userDataKey = `user_data_${this.username}`;
|
| 78 |
+
sessionStorage.setItem(userDataKey, JSON.stringify(this.data));
|
| 79 |
+
|
| 80 |
+
// ✅ 通过后端保存到 Redis
|
| 81 |
+
const response = await fetch('/api/user/data/sync', {
|
| 82 |
+
method: 'POST',
|
| 83 |
+
headers: {
|
| 84 |
+
'Content-Type': 'application/json'
|
| 85 |
+
},
|
| 86 |
+
body: JSON.stringify({
|
| 87 |
+
username: this.username,
|
| 88 |
+
data: updates
|
| 89 |
+
})
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
if (response.ok) {
|
| 93 |
+
console.log('✅ 用户数据已同步到 Redis:', Object.keys(updates));
|
| 94 |
+
this.pendingUpdates = {};
|
| 95 |
+
return true;
|
| 96 |
+
} else {
|
| 97 |
+
console.warn('⚠️ 同步失败,数据已保存到本地');
|
| 98 |
+
return false;
|
| 99 |
+
}
|
| 100 |
+
} catch (error) {
|
| 101 |
+
console.error('❌ 同步失败:', error);
|
| 102 |
+
return false;
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// ==================== 便捷方法 ====================
|
| 107 |
+
|
| 108 |
+
getFavorites() {
|
| 109 |
+
return this.data.favorite_channels || [];
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
setFavorites(favorites) {
|
| 113 |
+
this.data.favorite_channels = Array.isArray(favorites) ? favorites : [];
|
| 114 |
+
this.markChanged('favorite_channels');
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
getDownloadConcurrency() {
|
| 118 |
+
return this.data.download_concurrency || 16;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
setDownloadConcurrency(concurrency) {
|
| 122 |
+
const value = parseInt(concurrency);
|
| 123 |
+
if (value >= 1 && value <= 32) {
|
| 124 |
+
this.data.download_concurrency = value;
|
| 125 |
+
this.markChanged('download_concurrency');
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
getBatchConcurrency() {
|
| 130 |
+
return this.data.batch_download_concurrency || 3;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
setBatchConcurrency(concurrency) {
|
| 134 |
+
const value = parseInt(concurrency);
|
| 135 |
+
if (value >= 1 && value <= 10) {
|
| 136 |
+
this.data.batch_download_concurrency = value;
|
| 137 |
+
this.markChanged('batch_download_concurrency');
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
getFabPosition() {
|
| 142 |
+
return this.data.fab_position || { bottom: 30, right: 30 };
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
setFabPosition(position) {
|
| 146 |
+
if (position && typeof position === 'object') {
|
| 147 |
+
this.data.fab_position = position;
|
| 148 |
+
this.markChanged('fab_position');
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
getPlaybackHistory() {
|
| 153 |
+
return this.data.playback_history || [];
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
addPlaybackHistory(item) {
|
| 157 |
+
if (!Array.isArray(this.data.playback_history)) {
|
| 158 |
+
this.data.playback_history = [];
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// 去重
|
| 162 |
+
this.data.playback_history = this.data.playback_history.filter(
|
| 163 |
+
h => h.path !== item.path
|
| 164 |
+
);
|
| 165 |
+
|
| 166 |
+
// 添加到开头
|
| 167 |
+
this.data.playback_history.unshift(item);
|
| 168 |
+
|
| 169 |
+
// 最多保留 50 条
|
| 170 |
+
if (this.data.playback_history.length > 50) {
|
| 171 |
+
this.data.playback_history = this.data.playback_history.slice(0, 50);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
this.markChanged('playback_history');
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
getProgramReminders() {
|
| 178 |
+
return this.data.program_reminders || [];
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
addProgramReminder(reminder) {
|
| 182 |
+
if (!Array.isArray(this.data.program_reminders)) {
|
| 183 |
+
this.data.program_reminders = [];
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
const exists = this.data.program_reminders.some(
|
| 187 |
+
r => r.title === reminder.title && r.startTime === reminder.startTime
|
| 188 |
+
);
|
| 189 |
+
|
| 190 |
+
if (!exists) {
|
| 191 |
+
this.data.program_reminders.push(reminder);
|
| 192 |
+
this.markChanged('program_reminders');
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
removeProgramReminder(reminderId) {
|
| 197 |
+
if (Array.isArray(this.data.program_reminders)) {
|
| 198 |
+
this.data.program_reminders = this.data.program_reminders.filter(
|
| 199 |
+
r => r.id !== reminderId
|
| 200 |
+
);
|
| 201 |
+
this.markChanged('program_reminders');
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
// 创建全局单例
|
| 207 |
+
window.userDataSync = new UserDataSync();
|
| 208 |
+
|
| 209 |
+
// 页面卸载时保存
|
| 210 |
+
window.addEventListener('beforeunload', () => {
|
| 211 |
+
if (window.userDataSync && window.userDataSync.isInitialized) {
|
| 212 |
+
window.userDataSync.save(true);
|
| 213 |
+
}
|
| 214 |
+
});
|
| 215 |
+
|
| 216 |
+
console.log('✅ UserDataSync 已加载(无需 API)');
|
| 217 |
+
|
| 218 |
+
})();
|
static/templates/api-test.html
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div class="api-test-page">
|
| 2 |
+
<div class="api-tester">
|
| 3 |
+
<h2 style="font-size: 1.5em; color: var(--dark); margin-bottom: 20px;">🔧 API 接口测试工具</h2>
|
| 4 |
+
<p style="color: #64748b; margin-bottom: 30px;">测试后端 API 接口的响应速度和数据返回</p>
|
| 5 |
+
|
| 6 |
+
<div class="form-group">
|
| 7 |
+
<label for="apiEndpoint">📡 选择 API 端点</label>
|
| 8 |
+
<select id="apiEndpoint" class="form-control">
|
| 9 |
+
<option value="/health">GET /health - 健康检查</option>
|
| 10 |
+
<option value="/api/list">GET /api/list - 获取频道列表</option>
|
| 11 |
+
<option value="/api/refresh?type=all">GET /api/refresh - 刷新所有缓存</option>
|
| 12 |
+
<option value="/api/refresh?type=cid">GET /api/refresh - 刷新 CID</option>
|
| 13 |
+
<option value="/api/refresh?type=auth">GET /api/refresh - 刷新认证</option>
|
| 14 |
+
</select>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<button id="testBtn" class="btn btn-primary" style="width: 100%; padding: 16px; font-size: 1.05em;">
|
| 18 |
+
🚀 发送请求
|
| 19 |
+
</button>
|
| 20 |
+
|
| 21 |
+
<div class="response-container" style="margin-top: 30px;">
|
| 22 |
+
<h3 style="margin-bottom: 15px; color: var(--dark); font-size: 1.2em; display: flex; align-items: center; gap: 10px;">
|
| 23 |
+
📊 响应结果
|
| 24 |
+
</h3>
|
| 25 |
+
<pre id="apiResponse" style="background: #1e293b; color: #e2e8f0; padding: 20px; border-radius: 12px; overflow-x: auto; font-family: 'Courier New', monospace; font-size: 0.9em; line-height: 1.6; max-height: 500px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);">⏳ 等待发送请求...
|
| 26 |
+
|
| 27 |
+
💡 提示:选择一个 API 端点后点击"发送请求"按钮</pre>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
<script>
|
| 33 |
+
(function() {
|
| 34 |
+
'use strict';
|
| 35 |
+
|
| 36 |
+
const API = window.location.origin;
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
const debounce = window.MediaGatewayUtils?.debounce || function(func, wait) {
|
| 40 |
+
let timeout;
|
| 41 |
+
return function(...args) {
|
| 42 |
+
clearTimeout(timeout);
|
| 43 |
+
timeout = setTimeout(() => func(...args), wait);
|
| 44 |
+
};
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
async function test() {
|
| 48 |
+
const sel = document.getElementById('apiEndpoint');
|
| 49 |
+
const res = document.getElementById('apiResponse');
|
| 50 |
+
const btn = document.getElementById('testBtn');
|
| 51 |
+
|
| 52 |
+
if (!sel || !res || !btn) return;
|
| 53 |
+
|
| 54 |
+
const endpoint = sel.value;
|
| 55 |
+
|
| 56 |
+
btn.disabled = true;
|
| 57 |
+
btn.innerHTML = '<div class="loading-spinner-large" style="width: 20px; height: 20px; border-width: 3px; display: inline-block; margin-right: 10px;"></div>请求中...';
|
| 58 |
+
|
| 59 |
+
res.textContent = '⏳ 正在发送请求...\n\n请稍候...';
|
| 60 |
+
|
| 61 |
+
try {
|
| 62 |
+
const token = sessionStorage.getItem('admin_token');
|
| 63 |
+
const headers = {};
|
| 64 |
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
| 65 |
+
|
| 66 |
+
const start = Date.now();
|
| 67 |
+
const response = await fetch(`${API}${endpoint}`, { headers });
|
| 68 |
+
const time = Date.now() - start;
|
| 69 |
+
const data = await response.json();
|
| 70 |
+
|
| 71 |
+
const result = {
|
| 72 |
+
success: response.ok,
|
| 73 |
+
status: response.status,
|
| 74 |
+
statusText: response.statusText,
|
| 75 |
+
responseTime: `${time}ms`,
|
| 76 |
+
headers: Object.fromEntries(response.headers.entries()),
|
| 77 |
+
data: data
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
res.textContent = JSON.stringify(result, null, 2);
|
| 81 |
+
|
| 82 |
+
if (window.MediaGatewayUtils) {
|
| 83 |
+
window.MediaGatewayUtils.showNotification(`✅ 请求成功 (${time}ms)`, 'success');
|
| 84 |
+
}
|
| 85 |
+
} catch (e) {
|
| 86 |
+
res.textContent = `❌ 请求失败\n\n错误信息:\n${e.message}\n\n堆栈追踪:\n${e.stack || '无'}`;
|
| 87 |
+
|
| 88 |
+
if (window.MediaGatewayUtils) {
|
| 89 |
+
window.MediaGatewayUtils.showNotification('❌ 请求失败', 'error');
|
| 90 |
+
}
|
| 91 |
+
} finally {
|
| 92 |
+
btn.disabled = false;
|
| 93 |
+
btn.innerHTML = '🚀 发送请求';
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
window.initApiTestPage = function() {
|
| 98 |
+
const btn = document.getElementById('testBtn');
|
| 99 |
+
if (btn) btn.addEventListener('click', debounce(test, 300));
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
setTimeout(window.initApiTestPage, 0);
|
| 103 |
+
})();
|
| 104 |
+
</script>
|
static/templates/cache.html
ADDED
|
@@ -0,0 +1,1006 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div class="cache-page">
|
| 2 |
+
<div class="section-header">
|
| 3 |
+
<div>
|
| 4 |
+
<h2 style="font-size: 1.5em; color: var(--dark); margin: 0;">🗄️ 缓存管理面板</h2>
|
| 5 |
+
<p style="color: #64748b; margin-top: 5px;">查看和管理系统缓存状态</p>
|
| 6 |
+
</div>
|
| 7 |
+
<button id="refreshBtn" class="btn btn-primary">
|
| 8 |
+
🔄 刷新状态
|
| 9 |
+
</button>
|
| 10 |
+
</div>
|
| 11 |
+
|
| 12 |
+
<div id="cacheStats" class="cache-grid">
|
| 13 |
+
<div class="skeleton skeleton-card"></div>
|
| 14 |
+
<div class="skeleton skeleton-card"></div>
|
| 15 |
+
<div class="skeleton skeleton-card"></div>
|
| 16 |
+
<div class="skeleton skeleton-card"></div>
|
| 17 |
+
<div class="skeleton skeleton-card"></div>
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
<!-- EPG 缓存详情 -->
|
| 21 |
+
<div class="epg-cache-section" id="epgCacheSection" style="display: none;">
|
| 22 |
+
<h3 style="margin-bottom: 20px; color: var(--dark); font-size: 1.3em;">📅 EPG 缓存详情</h3>
|
| 23 |
+
<div id="epgCacheDetails" class="epg-cache-details"></div>
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
<div class="cache-actions">
|
| 27 |
+
<h3>🧹 清理缓存</h3>
|
| 28 |
+
<p style="color: #64748b; margin-bottom: 15px; font-size: 0.9em;">选择要清理的缓存类型,清理后将重新获取数据</p>
|
| 29 |
+
<div class="button-group">
|
| 30 |
+
<button class="btn btn-warning" onclick="clearCache('cid')">
|
| 31 |
+
🔑 清理 CID
|
| 32 |
+
</button>
|
| 33 |
+
<button class="btn btn-warning" onclick="clearCache('auth')">
|
| 34 |
+
🎫 清理认证
|
| 35 |
+
</button>
|
| 36 |
+
<button class="btn btn-warning" onclick="clearCache('channels')">
|
| 37 |
+
📺 清理频道
|
| 38 |
+
</button>
|
| 39 |
+
<button class="btn btn-warning" onclick="clearCache('streams')">
|
| 40 |
+
🎬 清理流
|
| 41 |
+
</button>
|
| 42 |
+
<button class="btn btn-warning" onclick="clearCache('epg')">
|
| 43 |
+
📅 清理 EPG
|
| 44 |
+
</button>
|
| 45 |
+
<button class="btn btn-danger" onclick="clearCache('all')">
|
| 46 |
+
🗑️ 清理全部
|
| 47 |
+
</button>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<!-- ✅ 节目详情弹窗 -->
|
| 53 |
+
<div class="epg-detail-modal" id="epgDetailModal">
|
| 54 |
+
<div class="epg-detail-container">
|
| 55 |
+
<div class="epg-detail-header">
|
| 56 |
+
<h3 id="epgDetailTitle">📺 节目详情</h3>
|
| 57 |
+
<button class="epg-detail-close" onclick="closeEpgDetail()">✕</button>
|
| 58 |
+
</div>
|
| 59 |
+
<div class="epg-detail-body">
|
| 60 |
+
<div id="epgDetailContent" class="epg-detail-content"></div>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<style>
|
| 66 |
+
/* 缓存卡片样式增强 */
|
| 67 |
+
.cache-card {
|
| 68 |
+
background: white;
|
| 69 |
+
padding: 20px;
|
| 70 |
+
border-radius: 15px;
|
| 71 |
+
border-left: 4px solid var(--primary);
|
| 72 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
| 73 |
+
contain: layout style paint;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.cache-card h3 {
|
| 77 |
+
color: var(--dark);
|
| 78 |
+
margin-bottom: 15px;
|
| 79 |
+
font-size: 1.1em;
|
| 80 |
+
font-weight: 700;
|
| 81 |
+
display: flex;
|
| 82 |
+
align-items: center;
|
| 83 |
+
gap: 8px;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.cache-detail {
|
| 87 |
+
display: flex;
|
| 88 |
+
flex-direction: column;
|
| 89 |
+
padding: 10px 0;
|
| 90 |
+
border-bottom: 1px solid var(--border);
|
| 91 |
+
font-size: 0.85em;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.cache-detail:last-child {
|
| 95 |
+
border-bottom: none;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.cache-label {
|
| 99 |
+
font-weight: 600;
|
| 100 |
+
color: #64748b;
|
| 101 |
+
margin-bottom: 5px;
|
| 102 |
+
font-size: 0.9em;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.cache-value {
|
| 106 |
+
font-family: 'Courier New', monospace;
|
| 107 |
+
color: var(--dark);
|
| 108 |
+
font-weight: 600;
|
| 109 |
+
word-break: break-all;
|
| 110 |
+
background: #f8fafc;
|
| 111 |
+
padding: 8px 10px;
|
| 112 |
+
border-radius: 6px;
|
| 113 |
+
font-size: 0.85em;
|
| 114 |
+
line-height: 1.5;
|
| 115 |
+
border: 1px solid #e2e8f0;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.cache-value.clickable {
|
| 119 |
+
cursor: pointer;
|
| 120 |
+
position: relative;
|
| 121 |
+
padding-right: 35px;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.cache-value.clickable:hover {
|
| 125 |
+
background: #f1f5f9;
|
| 126 |
+
border-color: #cbd5e1;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.copy-btn {
|
| 130 |
+
position: absolute;
|
| 131 |
+
right: 8px;
|
| 132 |
+
top: 50%;
|
| 133 |
+
transform: translateY(-50%);
|
| 134 |
+
background: var(--primary);
|
| 135 |
+
color: white;
|
| 136 |
+
border: none;
|
| 137 |
+
padding: 4px 8px;
|
| 138 |
+
border-radius: 4px;
|
| 139 |
+
cursor: pointer;
|
| 140 |
+
font-size: 0.8em;
|
| 141 |
+
transition: all 0.2s;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.copy-btn:hover {
|
| 145 |
+
background: var(--primary-dark);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.copy-btn:active {
|
| 149 |
+
transform: translateY(-50%) scale(0.95);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.cache-value-short {
|
| 153 |
+
color: #94a3b8;
|
| 154 |
+
font-style: italic;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/* EPG 缓存详情样式 */
|
| 158 |
+
.epg-cache-section {
|
| 159 |
+
background: white;
|
| 160 |
+
padding: 25px;
|
| 161 |
+
border-radius: 15px;
|
| 162 |
+
margin-bottom: 25px;
|
| 163 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.epg-cache-details {
|
| 167 |
+
display: grid;
|
| 168 |
+
gap: 20px;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.epg-summary-box {
|
| 172 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 173 |
+
color: white;
|
| 174 |
+
padding: 20px;
|
| 175 |
+
border-radius: 12px;
|
| 176 |
+
display: grid;
|
| 177 |
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
| 178 |
+
gap: 15px;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.epg-summary-item {
|
| 182 |
+
text-align: center;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.epg-summary-item .label {
|
| 186 |
+
font-size: 0.85em;
|
| 187 |
+
opacity: 0.9;
|
| 188 |
+
margin-bottom: 5px;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.epg-summary-item .value {
|
| 192 |
+
font-size: 1.8em;
|
| 193 |
+
font-weight: 700;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.epg-detail-grid {
|
| 197 |
+
display: grid;
|
| 198 |
+
grid-template-columns: 1fr 1fr;
|
| 199 |
+
gap: 20px;
|
| 200 |
+
margin-top: 20px;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.epg-detail-box {
|
| 204 |
+
background: #f8fafc;
|
| 205 |
+
padding: 15px;
|
| 206 |
+
border-radius: 10px;
|
| 207 |
+
border: 1px solid #e2e8f0;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.epg-detail-box h4 {
|
| 211 |
+
margin: 0 0 15px 0;
|
| 212 |
+
color: var(--dark);
|
| 213 |
+
font-size: 1em;
|
| 214 |
+
font-weight: 700;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.epg-list {
|
| 218 |
+
max-height: 300px;
|
| 219 |
+
overflow-y: auto;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.epg-list-item {
|
| 223 |
+
padding: 10px;
|
| 224 |
+
background: white;
|
| 225 |
+
border-radius: 8px;
|
| 226 |
+
margin-bottom: 8px;
|
| 227 |
+
font-size: 0.85em;
|
| 228 |
+
border: 1px solid #e2e8f0;
|
| 229 |
+
cursor: pointer;
|
| 230 |
+
transition: all 0.2s ease;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.epg-list-item:hover {
|
| 234 |
+
border-color: var(--primary);
|
| 235 |
+
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.2);
|
| 236 |
+
transform: translateX(4px);
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.epg-list-item .channel-id {
|
| 240 |
+
font-weight: 700;
|
| 241 |
+
color: var(--primary);
|
| 242 |
+
margin-bottom: 5px;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.epg-list-item .info {
|
| 246 |
+
color: #64748b;
|
| 247 |
+
display: flex;
|
| 248 |
+
justify-content: space-between;
|
| 249 |
+
flex-wrap: wrap;
|
| 250 |
+
gap: 8px;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
/* ✅ 节目详情弹窗样式 */
|
| 254 |
+
.epg-detail-modal {
|
| 255 |
+
display: none;
|
| 256 |
+
position: fixed;
|
| 257 |
+
top: 0;
|
| 258 |
+
left: 0;
|
| 259 |
+
width: 100%;
|
| 260 |
+
height: 100%;
|
| 261 |
+
background: rgba(30, 41, 59, 0.95);
|
| 262 |
+
backdrop-filter: blur(10px);
|
| 263 |
+
z-index: 9999;
|
| 264 |
+
align-items: center;
|
| 265 |
+
justify-content: center;
|
| 266 |
+
padding: 20px;
|
| 267 |
+
overflow-y: auto;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.epg-detail-modal.show {
|
| 271 |
+
display: flex;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.epg-detail-container {
|
| 275 |
+
background: white;
|
| 276 |
+
border-radius: 20px;
|
| 277 |
+
max-width: 1000px;
|
| 278 |
+
width: 100%;
|
| 279 |
+
max-height: 90vh;
|
| 280 |
+
overflow: hidden;
|
| 281 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
| 282 |
+
animation: modalSlideIn 0.3s ease;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
@keyframes modalSlideIn {
|
| 286 |
+
from {
|
| 287 |
+
opacity: 0;
|
| 288 |
+
transform: translateY(-30px);
|
| 289 |
+
}
|
| 290 |
+
to {
|
| 291 |
+
opacity: 1;
|
| 292 |
+
transform: translateY(0);
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.epg-detail-header {
|
| 297 |
+
padding: 20px 25px;
|
| 298 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 299 |
+
color: white;
|
| 300 |
+
display: flex;
|
| 301 |
+
justify-content: space-between;
|
| 302 |
+
align-items: center;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.epg-detail-header h3 {
|
| 306 |
+
margin: 0;
|
| 307 |
+
font-size: 1.4em;
|
| 308 |
+
font-weight: 700;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.epg-detail-close {
|
| 312 |
+
background: rgba(255, 255, 255, 0.2);
|
| 313 |
+
border: none;
|
| 314 |
+
color: white;
|
| 315 |
+
font-size: 1.5em;
|
| 316 |
+
width: 40px;
|
| 317 |
+
height: 40px;
|
| 318 |
+
border-radius: 50%;
|
| 319 |
+
cursor: pointer;
|
| 320 |
+
display: flex;
|
| 321 |
+
align-items: center;
|
| 322 |
+
justify-content: center;
|
| 323 |
+
transition: all 0.2s ease;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.epg-detail-close:hover {
|
| 327 |
+
background: rgba(255, 255, 255, 0.3);
|
| 328 |
+
transform: rotate(90deg);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.epg-detail-body {
|
| 332 |
+
padding: 25px;
|
| 333 |
+
max-height: calc(90vh - 80px);
|
| 334 |
+
overflow-y: auto;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.epg-detail-content {
|
| 338 |
+
display: flex;
|
| 339 |
+
flex-direction: column;
|
| 340 |
+
gap: 15px;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.date-section {
|
| 344 |
+
background: #f8fafc;
|
| 345 |
+
padding: 15px;
|
| 346 |
+
border-radius: 10px;
|
| 347 |
+
border-left: 4px solid var(--primary);
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.date-section h4 {
|
| 351 |
+
margin: 0 0 15px 0;
|
| 352 |
+
color: var(--primary);
|
| 353 |
+
font-size: 1.1em;
|
| 354 |
+
font-weight: 700;
|
| 355 |
+
display: flex;
|
| 356 |
+
align-items: center;
|
| 357 |
+
gap: 8px;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
.program-item {
|
| 361 |
+
background: white;
|
| 362 |
+
padding: 12px;
|
| 363 |
+
border-radius: 8px;
|
| 364 |
+
margin-bottom: 8px;
|
| 365 |
+
border: 1px solid #e2e8f0;
|
| 366 |
+
transition: all 0.2s ease;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.program-item:hover {
|
| 370 |
+
border-color: var(--primary);
|
| 371 |
+
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.program-time {
|
| 375 |
+
font-weight: 700;
|
| 376 |
+
color: var(--primary);
|
| 377 |
+
margin-bottom: 5px;
|
| 378 |
+
font-size: 0.9em;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.program-title {
|
| 382 |
+
color: var(--dark);
|
| 383 |
+
font-weight: 600;
|
| 384 |
+
margin-bottom: 3px;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.program-desc {
|
| 388 |
+
color: #64748b;
|
| 389 |
+
font-size: 0.85em;
|
| 390 |
+
line-height: 1.4;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
@media (max-width: 768px) {
|
| 394 |
+
.epg-detail-grid {
|
| 395 |
+
grid-template-columns: 1fr;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.epg-summary-box {
|
| 399 |
+
grid-template-columns: 1fr;
|
| 400 |
+
}
|
| 401 |
+
}
|
| 402 |
+
</style>
|
| 403 |
+
|
| 404 |
+
<script>
|
| 405 |
+
(function() {
|
| 406 |
+
'use strict';
|
| 407 |
+
|
| 408 |
+
const API = window.location.origin;
|
| 409 |
+
|
| 410 |
+
// ✅ 全局变量:频道映射
|
| 411 |
+
let channelMap = {};
|
| 412 |
+
|
| 413 |
+
// 防抖
|
| 414 |
+
const debounce = window.MediaGatewayUtils?.debounce || function(func, wait) {
|
| 415 |
+
let timeout;
|
| 416 |
+
return function(...args) {
|
| 417 |
+
clearTimeout(timeout);
|
| 418 |
+
timeout = setTimeout(() => func(...args), wait);
|
| 419 |
+
};
|
| 420 |
+
};
|
| 421 |
+
|
| 422 |
+
// 复制到剪贴板
|
| 423 |
+
function copyToClipboard(text, btnElement) {
|
| 424 |
+
navigator.clipboard.writeText(text).then(() => {
|
| 425 |
+
const originalText = btnElement.textContent;
|
| 426 |
+
btnElement.textContent = '✓';
|
| 427 |
+
btnElement.style.background = '#10b981';
|
| 428 |
+
|
| 429 |
+
setTimeout(() => {
|
| 430 |
+
btnElement.textContent = originalText;
|
| 431 |
+
btnElement.style.background = '';
|
| 432 |
+
}, 1500);
|
| 433 |
+
|
| 434 |
+
if (window.MediaGatewayUtils) {
|
| 435 |
+
window.MediaGatewayUtils.showNotification('✅ 已复制到剪贴板', 'success');
|
| 436 |
+
}
|
| 437 |
+
}).catch(err => {
|
| 438 |
+
console.error('复制失败:', err);
|
| 439 |
+
if (window.MediaGatewayUtils) {
|
| 440 |
+
window.MediaGatewayUtils.showNotification('❌ 复制失败', 'error');
|
| 441 |
+
}
|
| 442 |
+
});
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
// HTML 转义函数
|
| 446 |
+
function escapeHtml(text) {
|
| 447 |
+
const div = document.createElement('div');
|
| 448 |
+
div.textContent = text;
|
| 449 |
+
return div.innerHTML;
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
// ✅ 创建可复制的值元素(支持显示部分但复制全部)
|
| 453 |
+
function createCopyableValue(value, label, showPartial = false) {
|
| 454 |
+
if (!value) return '<span class="cache-value-short">未缓存</span>';
|
| 455 |
+
|
| 456 |
+
const id = 'copy_' + Math.random().toString(36).substr(2, 9);
|
| 457 |
+
|
| 458 |
+
// ✅ 如果需要部分显示
|
| 459 |
+
let displayValue = value;
|
| 460 |
+
if (showPartial && value.length > 40) {
|
| 461 |
+
displayValue = value.substring(0, 20) + '...' + value.substring(value.length - 20);
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
return `
|
| 465 |
+
<div class="cache-value clickable" style="position: relative;">
|
| 466 |
+
<span id="${id}_text" data-full-value="${escapeHtml(value)}">${escapeHtml(displayValue)}</span>
|
| 467 |
+
<button class="copy-btn" onclick="window.copyCache('${id}_text')">📋 复制</button>
|
| 468 |
+
</div>
|
| 469 |
+
`;
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
// ✅ 全局复制函数(复制完整值)
|
| 473 |
+
window.copyCache = function(elementId) {
|
| 474 |
+
const element = document.getElementById(elementId);
|
| 475 |
+
if (element) {
|
| 476 |
+
// ✅ 优先使用 data-full-value 属性(完整值)
|
| 477 |
+
const fullValue = element.getAttribute('data-full-value');
|
| 478 |
+
const text = fullValue || element.textContent;
|
| 479 |
+
const btn = element.nextElementSibling;
|
| 480 |
+
copyToClipboard(text, btn);
|
| 481 |
+
}
|
| 482 |
+
};
|
| 483 |
+
|
| 484 |
+
// ✅ 加载频道列表
|
| 485 |
+
async function loadChannelMap() {
|
| 486 |
+
try {
|
| 487 |
+
const res = await fetch(`${API}/api/list`);
|
| 488 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 489 |
+
|
| 490 |
+
const data = await res.json();
|
| 491 |
+
if (data.success) {
|
| 492 |
+
const channels = data.channels || [];
|
| 493 |
+
channelMap = {};
|
| 494 |
+
channels.forEach(ch => {
|
| 495 |
+
channelMap[ch.id] = ch.name;
|
| 496 |
+
});
|
| 497 |
+
console.log('✅ 频道映射加载完成:', Object.keys(channelMap).length, '个频道');
|
| 498 |
+
}
|
| 499 |
+
} catch (e) {
|
| 500 |
+
console.error('❌ 加载频道列表失败:', e);
|
| 501 |
+
}
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
// ✅ 获取频道名称
|
| 505 |
+
function getChannelName(channelId) {
|
| 506 |
+
return channelMap[channelId] || `频道 ${channelId}`;
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
// ✅ 格式化时间
|
| 510 |
+
function formatTime(timestamp) {
|
| 511 |
+
const d = new Date(timestamp * 1000);
|
| 512 |
+
return d.toLocaleTimeString('ja-JP', {
|
| 513 |
+
timeZone: 'Asia/Tokyo',
|
| 514 |
+
hour: '2-digit',
|
| 515 |
+
minute: '2-digit',
|
| 516 |
+
hour12: false
|
| 517 |
+
});
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
// ✅ 打开节目详情弹窗
|
| 521 |
+
window.openEpgDetail = async function(channelId) {
|
| 522 |
+
console.log('📺 打开频道详情:', channelId);
|
| 523 |
+
|
| 524 |
+
const modal = document.getElementById('epgDetailModal');
|
| 525 |
+
const title = document.getElementById('epgDetailTitle');
|
| 526 |
+
const content = document.getElementById('epgDetailContent');
|
| 527 |
+
|
| 528 |
+
if (!modal || !content) return;
|
| 529 |
+
|
| 530 |
+
const channelName = getChannelName(channelId);
|
| 531 |
+
if (title) title.textContent = `📺 ${channelName} - 缓存节目`;
|
| 532 |
+
|
| 533 |
+
content.innerHTML = `
|
| 534 |
+
<div style="text-align: center; padding: 40px;">
|
| 535 |
+
<div class="loading-spinner-large"></div>
|
| 536 |
+
<p style="margin-top: 15px; color: #64748b;">加载中...</p>
|
| 537 |
+
</div>
|
| 538 |
+
`;
|
| 539 |
+
|
| 540 |
+
modal.classList.add('show');
|
| 541 |
+
|
| 542 |
+
try {
|
| 543 |
+
// 获取该频道的所有缓存数据
|
| 544 |
+
const res = await fetch(`${API}/health`);
|
| 545 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 546 |
+
|
| 547 |
+
const data = await res.json();
|
| 548 |
+
const epgDetail = data.cache.epg_detail;
|
| 549 |
+
|
| 550 |
+
if (!epgDetail || !epgDetail.by_channel || !epgDetail.by_channel[channelId]) {
|
| 551 |
+
content.innerHTML = `
|
| 552 |
+
<div class="empty-state">
|
| 553 |
+
<div class="empty-state-icon">📅</div>
|
| 554 |
+
<div class="empty-state-text">该频道暂无缓存数据</div>
|
| 555 |
+
</div>
|
| 556 |
+
`;
|
| 557 |
+
return;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
const channelData = epgDetail.by_channel[channelId];
|
| 561 |
+
const dates = channelData.dates.sort();
|
| 562 |
+
const dateDetails = await Promise.all(
|
| 563 |
+
dates.map(async (date) => {
|
| 564 |
+
try {
|
| 565 |
+
const epgRes = await fetch(`${API}/api/epg?vid=${channelId}&date=${date}`);
|
| 566 |
+
if (!epgRes.ok) return null;
|
| 567 |
+
|
| 568 |
+
const epgData = await epgRes.json();
|
| 569 |
+
if (!epgData.success) return null;
|
| 570 |
+
|
| 571 |
+
return {
|
| 572 |
+
date: date,
|
| 573 |
+
programs: epgData.epg || []
|
| 574 |
+
};
|
| 575 |
+
} catch (e) {
|
| 576 |
+
console.error(`获取 ${date} 数据失败:`, e);
|
| 577 |
+
return null;
|
| 578 |
+
}
|
| 579 |
+
})
|
| 580 |
+
);
|
| 581 |
+
|
| 582 |
+
// 渲染节目列表
|
| 583 |
+
let html = '';
|
| 584 |
+
|
| 585 |
+
dateDetails.filter(d => d !== null).forEach(dateData => {
|
| 586 |
+
const { date, programs } = dateData;
|
| 587 |
+
const programCount = programs.length;
|
| 588 |
+
|
| 589 |
+
html += `
|
| 590 |
+
<div class="date-section">
|
| 591 |
+
<h4>
|
| 592 |
+
📅 ${date}
|
| 593 |
+
<span style="font-size: 0.85em; opacity: 0.8; font-weight: 600;">
|
| 594 |
+
(${programCount} 个节目)
|
| 595 |
+
</span>
|
| 596 |
+
</h4>
|
| 597 |
+
`;
|
| 598 |
+
|
| 599 |
+
if (programs.length === 0) {
|
| 600 |
+
html += `<p style="color: #94a3b8; text-align: center; padding: 20px;">暂无节目数据</p>`;
|
| 601 |
+
} else {
|
| 602 |
+
programs.forEach(prog => {
|
| 603 |
+
const startTime = formatTime(prog.time);
|
| 604 |
+
const endTime = prog.time_end ? formatTime(prog.time_end) : '未知';
|
| 605 |
+
const title = prog.title || prog.name || '未知节目';
|
| 606 |
+
const desc = prog.description || '';
|
| 607 |
+
|
| 608 |
+
html += `
|
| 609 |
+
<div class="program-item">
|
| 610 |
+
<div class="program-time">⏰ ${startTime} - ${endTime}</div>
|
| 611 |
+
<div class="program-title">${escapeHtml(title)}</div>
|
| 612 |
+
${desc ? `<div class="program-desc">📝 ${escapeHtml(desc)}</div>` : ''}
|
| 613 |
+
</div>
|
| 614 |
+
`;
|
| 615 |
+
});
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
html += `</div>`;
|
| 619 |
+
});
|
| 620 |
+
|
| 621 |
+
if (html === '') {
|
| 622 |
+
html = `
|
| 623 |
+
<div class="empty-state">
|
| 624 |
+
<div class="empty-state-icon">📅</div>
|
| 625 |
+
<div class="empty-state-text">暂无节目数据</div>
|
| 626 |
+
</div>
|
| 627 |
+
`;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
content.innerHTML = html;
|
| 631 |
+
|
| 632 |
+
} catch (e) {
|
| 633 |
+
console.error('加载失败:', e);
|
| 634 |
+
content.innerHTML = `
|
| 635 |
+
<div class="empty-state">
|
| 636 |
+
<div class="empty-state-icon" style="color: var(--danger);">❌</div>
|
| 637 |
+
<div class="empty-state-text">加载失败</div>
|
| 638 |
+
<div class="empty-state-subtitle">${e.message}</div>
|
| 639 |
+
</div>
|
| 640 |
+
`;
|
| 641 |
+
}
|
| 642 |
+
};
|
| 643 |
+
|
| 644 |
+
// ✅ 关闭节目详情弹窗
|
| 645 |
+
window.closeEpgDetail = function() {
|
| 646 |
+
const modal = document.getElementById('epgDetailModal');
|
| 647 |
+
if (modal) modal.classList.remove('show');
|
| 648 |
+
};
|
| 649 |
+
|
| 650 |
+
// 渲染 EPG 缓存详情
|
| 651 |
+
function renderEPGDetails(epgDetail) {
|
| 652 |
+
const section = document.getElementById('epgCacheSection');
|
| 653 |
+
const details = document.getElementById('epgCacheDetails');
|
| 654 |
+
|
| 655 |
+
if (!epgDetail || epgDetail.total_entries === 0) {
|
| 656 |
+
section.style.display = 'none';
|
| 657 |
+
return;
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
section.style.display = 'block';
|
| 661 |
+
|
| 662 |
+
let html = '';
|
| 663 |
+
|
| 664 |
+
// 摘要信息
|
| 665 |
+
if (epgDetail.summary) {
|
| 666 |
+
html += `
|
| 667 |
+
<div class="epg-summary-box">
|
| 668 |
+
<div class="epg-summary-item">
|
| 669 |
+
<div class="label">📦 缓存条目</div>
|
| 670 |
+
<div class="value">${epgDetail.total_entries}</div>
|
| 671 |
+
</div>
|
| 672 |
+
<div class="epg-summary-item">
|
| 673 |
+
<div class="label">📺 频道数</div>
|
| 674 |
+
<div class="value">${epgDetail.summary.total_channels}</div>
|
| 675 |
+
</div>
|
| 676 |
+
<div class="epg-summary-item">
|
| 677 |
+
<div class="label">📅 日期数</div>
|
| 678 |
+
<div class="value">${epgDetail.summary.total_dates}</div>
|
| 679 |
+
</div>
|
| 680 |
+
<div class="epg-summary-item">
|
| 681 |
+
<div class="label">🎬 节目数</div>
|
| 682 |
+
<div class="value">${epgDetail.summary.total_programs}</div>
|
| 683 |
+
</div>
|
| 684 |
+
</div>
|
| 685 |
+
`;
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
// 全量缓存状态
|
| 689 |
+
if (epgDetail.full_cache_available) {
|
| 690 |
+
html += `
|
| 691 |
+
<div style="background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); padding: 15px; border-radius: 10px; margin-top: 20px; border: 2px solid #6ee7b7;">
|
| 692 |
+
<div style="display: flex; align-items: center; gap: 10px; color: #065f46;">
|
| 693 |
+
<span style="font-size: 1.5em;">✅</span>
|
| 694 |
+
<div>
|
| 695 |
+
<div style="font-weight: 700; margin-bottom: 5px;">全量缓存已就绪</div>
|
| 696 |
+
<div style="font-size: 0.85em; opacity: 0.9;">
|
| 697 |
+
缓存时间: ${epgDetail.full_cache_time} (${epgDetail.full_cache_age} 前)
|
| 698 |
+
</div>
|
| 699 |
+
</div>
|
| 700 |
+
</div>
|
| 701 |
+
</div>
|
| 702 |
+
`;
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
// 详细信息
|
| 706 |
+
html += '<div class="epg-detail-grid">';
|
| 707 |
+
|
| 708 |
+
// 按频道统计
|
| 709 |
+
if (epgDetail.by_channel && Object.keys(epgDetail.by_channel).length > 0) {
|
| 710 |
+
html += `
|
| 711 |
+
<div class="epg-detail-box">
|
| 712 |
+
<h4>📺 按频道统计 (前10个)</h4>
|
| 713 |
+
<div class="epg-list">
|
| 714 |
+
`;
|
| 715 |
+
|
| 716 |
+
const channels = Object.entries(epgDetail.by_channel)
|
| 717 |
+
.sort((a, b) => b[1].program_count - a[1].program_count)
|
| 718 |
+
.slice(0, 10);
|
| 719 |
+
|
| 720 |
+
channels.forEach(([channelId, info]) => {
|
| 721 |
+
// ✅ 使用频道名称
|
| 722 |
+
const channelName = getChannelName(channelId);
|
| 723 |
+
|
| 724 |
+
html += `
|
| 725 |
+
<div class="epg-list-item" onclick="openEpgDetail('${channelId}')">
|
| 726 |
+
<div class="channel-id">${channelName}</div>
|
| 727 |
+
<div class="info">
|
| 728 |
+
<span>📅 ${info.dates.length} 个日期</span>
|
| 729 |
+
<span>🎬 ${info.program_count} 个节目</span>
|
| 730 |
+
</div>
|
| 731 |
+
</div>
|
| 732 |
+
`;
|
| 733 |
+
});
|
| 734 |
+
|
| 735 |
+
html += `
|
| 736 |
+
</div>
|
| 737 |
+
</div>
|
| 738 |
+
`;
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
// 按日期统计
|
| 742 |
+
if (epgDetail.by_date && Object.keys(epgDetail.by_date).length > 0) {
|
| 743 |
+
html += `
|
| 744 |
+
<div class="epg-detail-box">
|
| 745 |
+
<h4>📅 按日期统计</h4>
|
| 746 |
+
<div class="epg-list">
|
| 747 |
+
`;
|
| 748 |
+
|
| 749 |
+
const dates = Object.entries(epgDetail.by_date)
|
| 750 |
+
.sort((a, b) => b[0].localeCompare(a[0]));
|
| 751 |
+
|
| 752 |
+
dates.forEach(([date, info]) => {
|
| 753 |
+
html += `
|
| 754 |
+
<div class="epg-list-item">
|
| 755 |
+
<div class="channel-id">${date}</div>
|
| 756 |
+
<div class="info">
|
| 757 |
+
<span>📺 ${info.channels.length} 个频道</span>
|
| 758 |
+
<span>🎬 ${info.program_count} 个节目</span>
|
| 759 |
+
</div>
|
| 760 |
+
</div>
|
| 761 |
+
`;
|
| 762 |
+
});
|
| 763 |
+
|
| 764 |
+
html += `
|
| 765 |
+
</div>
|
| 766 |
+
</div>
|
| 767 |
+
`;
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
html += '</div>';
|
| 771 |
+
|
| 772 |
+
details.innerHTML = html;
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
async function load() {
|
| 776 |
+
const stats = document.getElementById('cacheStats');
|
| 777 |
+
if (!stats) return;
|
| 778 |
+
|
| 779 |
+
stats.innerHTML = `
|
| 780 |
+
<div class="skeleton skeleton-card"></div>
|
| 781 |
+
<div class="skeleton skeleton-card"></div>
|
| 782 |
+
<div class="skeleton skeleton-card"></div>
|
| 783 |
+
<div class="skeleton skeleton-card"></div>
|
| 784 |
+
<div class="skeleton skeleton-card"></div>
|
| 785 |
+
`;
|
| 786 |
+
|
| 787 |
+
// ✅ 加载频道映射
|
| 788 |
+
await loadChannelMap();
|
| 789 |
+
|
| 790 |
+
try {
|
| 791 |
+
const token = sessionStorage.getItem('admin_token');
|
| 792 |
+
const headers = {};
|
| 793 |
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
| 794 |
+
|
| 795 |
+
const res = await fetch(`${API}/health`, { headers });
|
| 796 |
+
const data = await res.json();
|
| 797 |
+
|
| 798 |
+
if (!data.cache) throw new Error('无法获取缓存信息');
|
| 799 |
+
|
| 800 |
+
const cache = data.cache;
|
| 801 |
+
|
| 802 |
+
const cacheCards = [
|
| 803 |
+
{
|
| 804 |
+
icon: '🔑',
|
| 805 |
+
title: 'CID 缓存',
|
| 806 |
+
color: '#6366f1',
|
| 807 |
+
data: cache.cid
|
| 808 |
+
},
|
| 809 |
+
{
|
| 810 |
+
icon: '🎫',
|
| 811 |
+
title: '认证缓存',
|
| 812 |
+
color: '#8b5cf6',
|
| 813 |
+
data: cache.auth
|
| 814 |
+
},
|
| 815 |
+
{
|
| 816 |
+
icon: '📺',
|
| 817 |
+
title: '频道缓存',
|
| 818 |
+
color: '#ec4899',
|
| 819 |
+
data: { cached: cache.channels }
|
| 820 |
+
},
|
| 821 |
+
{
|
| 822 |
+
icon: '🎬',
|
| 823 |
+
title: '流缓存',
|
| 824 |
+
color: '#f59e0b',
|
| 825 |
+
data: { cached: cache.streams > 0, count: cache.streams }
|
| 826 |
+
},
|
| 827 |
+
{
|
| 828 |
+
icon: '📅',
|
| 829 |
+
title: 'EPG 缓存',
|
| 830 |
+
color: '#10b981',
|
| 831 |
+
data: { cached: cache.epg > 0, count: cache.epg }
|
| 832 |
+
}
|
| 833 |
+
];
|
| 834 |
+
|
| 835 |
+
stats.innerHTML = cacheCards.map((card) => {
|
| 836 |
+
let content = '';
|
| 837 |
+
|
| 838 |
+
if (card.data.cached !== undefined) {
|
| 839 |
+
// 状态
|
| 840 |
+
content += `
|
| 841 |
+
<div class="cache-detail">
|
| 842 |
+
<span class="cache-label">状态</span>
|
| 843 |
+
<span class="cache-value">${card.data.cached ? '✅ 已缓存' : '❌ 未缓存'}</span>
|
| 844 |
+
</div>
|
| 845 |
+
`;
|
| 846 |
+
|
| 847 |
+
// CID 完整值
|
| 848 |
+
if (card.data.cached && card.data.value) {
|
| 849 |
+
content += `
|
| 850 |
+
<div class="cache-detail">
|
| 851 |
+
<span class="cache-label">完整 CID</span>
|
| 852 |
+
${createCopyableValue(card.data.value, 'CID', false)}
|
| 853 |
+
</div>
|
| 854 |
+
`;
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
// ✅ Access Token 完整值(显示部分但复制全部)
|
| 858 |
+
if (card.data.cached && card.data.token) {
|
| 859 |
+
content += `
|
| 860 |
+
<div class="cache-detail">
|
| 861 |
+
<span class="cache-label">Access Token</span>
|
| 862 |
+
${createCopyableValue(card.data.token, 'Token', true)}
|
| 863 |
+
</div>
|
| 864 |
+
`;
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
// 年龄
|
| 868 |
+
if (card.data.age) {
|
| 869 |
+
content += `
|
| 870 |
+
<div class="cache-detail">
|
| 871 |
+
<span class="cache-label">缓存时长</span>
|
| 872 |
+
<span class="cache-value">${card.data.age}</span>
|
| 873 |
+
</div>
|
| 874 |
+
`;
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
// 剩余时间
|
| 878 |
+
if (card.data.ttl) {
|
| 879 |
+
content += `
|
| 880 |
+
<div class="cache-detail">
|
| 881 |
+
<span class="cache-label">剩余有效期</span>
|
| 882 |
+
<span class="cache-value">${card.data.ttl}</span>
|
| 883 |
+
</div>
|
| 884 |
+
`;
|
| 885 |
+
}
|
| 886 |
+
|
| 887 |
+
// 存储位置
|
| 888 |
+
if (card.data.storage) {
|
| 889 |
+
content += `
|
| 890 |
+
<div class="cache-detail">
|
| 891 |
+
<span class="cache-label">存储位置</span>
|
| 892 |
+
<span class="cache-value">${card.data.storage}</span>
|
| 893 |
+
</div>
|
| 894 |
+
`;
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
// 数量
|
| 898 |
+
if (card.data.count !== undefined) {
|
| 899 |
+
content += `
|
| 900 |
+
<div class="cache-detail">
|
| 901 |
+
<span class="cache-label">缓存数量</span>
|
| 902 |
+
<span class="cache-value">${card.data.count} 个</span>
|
| 903 |
+
</div>
|
| 904 |
+
`;
|
| 905 |
+
}
|
| 906 |
+
}
|
| 907 |
+
|
| 908 |
+
return `
|
| 909 |
+
<div class="cache-card" style="border-left-color: ${card.color};">
|
| 910 |
+
<h3>${card.icon} ${card.title}</h3>
|
| 911 |
+
${content}
|
| 912 |
+
</div>
|
| 913 |
+
`;
|
| 914 |
+
}).join('');
|
| 915 |
+
|
| 916 |
+
// 渲染 EPG 缓存详情
|
| 917 |
+
if (cache.epg_detail) {
|
| 918 |
+
renderEPGDetails(cache.epg_detail);
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
if (window.MediaGatewayUtils) {
|
| 922 |
+
window.MediaGatewayUtils.showNotification('✅ 缓存状态已更新', 'success');
|
| 923 |
+
}
|
| 924 |
+
} catch (e) {
|
| 925 |
+
stats.innerHTML = `
|
| 926 |
+
<div style="grid-column: 1/-1; text-align: center; padding: 60px 30px; color: var(--danger);">
|
| 927 |
+
<div style="font-size: 60px; margin-bottom: 20px;">❌</div>
|
| 928 |
+
<h3>加载失败</h3>
|
| 929 |
+
<p style="color: #64748b; margin-top: 10px;">${e.message}</p>
|
| 930 |
+
</div>
|
| 931 |
+
`;
|
| 932 |
+
|
| 933 |
+
if (window.MediaGatewayUtils) {
|
| 934 |
+
window.MediaGatewayUtils.showNotification('❌ 加载失败', 'error');
|
| 935 |
+
}
|
| 936 |
+
}
|
| 937 |
+
}
|
| 938 |
+
|
| 939 |
+
window.clearCache = debounce(async function(type) {
|
| 940 |
+
const typeNames = {
|
| 941 |
+
'cid': 'CID',
|
| 942 |
+
'auth': '认证',
|
| 943 |
+
'channels': '频道',
|
| 944 |
+
'streams': '流',
|
| 945 |
+
'epg': 'EPG',
|
| 946 |
+
'all': '所有'
|
| 947 |
+
};
|
| 948 |
+
|
| 949 |
+
const msg = type === 'all'
|
| 950 |
+
? '⚠️ 确定要清理所有缓存吗?这将导致所有数据重新获取。'
|
| 951 |
+
: `确定要清理 ${typeNames[type]} 缓存吗?`;
|
| 952 |
+
|
| 953 |
+
if (!confirm(msg)) return;
|
| 954 |
+
|
| 955 |
+
try {
|
| 956 |
+
const token = sessionStorage.getItem('admin_token');
|
| 957 |
+
const headers = {};
|
| 958 |
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
| 959 |
+
|
| 960 |
+
const res = await fetch(`${API}/api/refresh?type=${type}`, { headers });
|
| 961 |
+
const data = await res.json();
|
| 962 |
+
|
| 963 |
+
if (!data.success) throw new Error(data.error || '清理失败');
|
| 964 |
+
|
| 965 |
+
if (window.MediaGatewayUtils) {
|
| 966 |
+
window.MediaGatewayUtils.showNotification(`✅ ${data.message}`, 'success');
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
await load();
|
| 970 |
+
} catch (e) {
|
| 971 |
+
if (window.MediaGatewayUtils) {
|
| 972 |
+
window.MediaGatewayUtils.showNotification('❌ 清理失败: ' + e.message, 'error');
|
| 973 |
+
}
|
| 974 |
+
}
|
| 975 |
+
}, 300);
|
| 976 |
+
|
| 977 |
+
window.initCachePage = function() {
|
| 978 |
+
load();
|
| 979 |
+
|
| 980 |
+
const btn = document.getElementById('refreshBtn');
|
| 981 |
+
if (btn) btn.addEventListener('click', debounce(load, 300));
|
| 982 |
+
|
| 983 |
+
// ✅ ESC 键关闭弹窗
|
| 984 |
+
document.addEventListener('keydown', (e) => {
|
| 985 |
+
if (e.key === 'Escape') {
|
| 986 |
+
const modal = document.getElementById('epgDetailModal');
|
| 987 |
+
if (modal && modal.classList.contains('show')) {
|
| 988 |
+
closeEpgDetail();
|
| 989 |
+
}
|
| 990 |
+
}
|
| 991 |
+
});
|
| 992 |
+
|
| 993 |
+
// ✅ 点击遮罩关闭弹窗
|
| 994 |
+
const modal = document.getElementById('epgDetailModal');
|
| 995 |
+
if (modal) {
|
| 996 |
+
modal.addEventListener('click', (e) => {
|
| 997 |
+
if (e.target === modal) {
|
| 998 |
+
closeEpgDetail();
|
| 999 |
+
}
|
| 1000 |
+
});
|
| 1001 |
+
}
|
| 1002 |
+
};
|
| 1003 |
+
|
| 1004 |
+
setTimeout(window.initCachePage, 0);
|
| 1005 |
+
})();
|
| 1006 |
+
</script>
|
static/templates/channels.html
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div class="channels-page">
|
| 2 |
+
<div class="section-header">
|
| 3 |
+
<div class="search-box" style="flex: 1; max-width: 400px;">
|
| 4 |
+
<input
|
| 5 |
+
type="text"
|
| 6 |
+
id="channelSearch"
|
| 7 |
+
placeholder="搜索频道..."
|
| 8 |
+
class="form-control"
|
| 9 |
+
>
|
| 10 |
+
</div>
|
| 11 |
+
<button id="refreshChannels" class="btn btn-primary">
|
| 12 |
+
🔄 刷新
|
| 13 |
+
</button>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
<div id="channelStats" class="stats-box">加载中...</div>
|
| 17 |
+
|
| 18 |
+
<div id="channelList" class="channel-grid"></div>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<script>
|
| 22 |
+
(function() {
|
| 23 |
+
'use strict';
|
| 24 |
+
|
| 25 |
+
const API = window.location.origin;
|
| 26 |
+
let channels = [];
|
| 27 |
+
let searchTimeout;
|
| 28 |
+
|
| 29 |
+
function renderBatch(list, startIdx = 0, batchSize = 50) {
|
| 30 |
+
const el = document.getElementById('channelList');
|
| 31 |
+
if (!el) return;
|
| 32 |
+
|
| 33 |
+
if (startIdx === 0) {
|
| 34 |
+
el.innerHTML = '';
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const fragment = document.createDocumentFragment();
|
| 38 |
+
const endIdx = Math.min(startIdx + batchSize, list.length);
|
| 39 |
+
|
| 40 |
+
for (let i = startIdx; i < endIdx; i++) {
|
| 41 |
+
const ch = list[i];
|
| 42 |
+
const div = document.createElement('div');
|
| 43 |
+
div.className = 'channel-card';
|
| 44 |
+
div.innerHTML = `
|
| 45 |
+
<div class="channel-name">${ch.name}</div>
|
| 46 |
+
<div class="channel-actions">
|
| 47 |
+
<button class="btn btn-success" data-no="${ch.no}" data-action="play">▶️ 播放</button>
|
| 48 |
+
<button class="btn btn-primary" data-id="${ch.id}" data-action="epg">📅 节目表</button>
|
| 49 |
+
</div>
|
| 50 |
+
`;
|
| 51 |
+
fragment.appendChild(div);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
el.appendChild(fragment);
|
| 55 |
+
|
| 56 |
+
if (endIdx < list.length) {
|
| 57 |
+
requestAnimationFrame(() => renderBatch(list, endIdx, batchSize));
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// ✅ 事件委托处理点击
|
| 62 |
+
document.addEventListener('click', (e) => {
|
| 63 |
+
const btn = e.target.closest('button[data-action]');
|
| 64 |
+
if (!btn) return;
|
| 65 |
+
|
| 66 |
+
const action = btn.dataset.action;
|
| 67 |
+
|
| 68 |
+
if (action === 'play') {
|
| 69 |
+
const no = btn.dataset.no;
|
| 70 |
+
console.log('🎬 频道列表点击播放,频道号:', no); // 调试日志
|
| 71 |
+
|
| 72 |
+
if (window.navigateToPlayer) {
|
| 73 |
+
console.log('✅ navigateToPlayer 存在,调用并传递 autoPlay=true'); // 调试日志
|
| 74 |
+
// ✅ 修复:明确传递 autoPlay=true
|
| 75 |
+
window.navigateToPlayer(no, true);
|
| 76 |
+
} else {
|
| 77 |
+
console.error('❌ navigateToPlayer 函数不存在'); // 调试日志
|
| 78 |
+
}
|
| 79 |
+
} else if (action === 'epg') {
|
| 80 |
+
const id = btn.dataset.id;
|
| 81 |
+
const today = new Date();
|
| 82 |
+
const jst = new Date(today.toLocaleString('en-US', { timeZone: 'Asia/Tokyo' }));
|
| 83 |
+
const date = jst.toISOString().split('T')[0];
|
| 84 |
+
if (window.navigateToEPG) {
|
| 85 |
+
window.navigateToEPG(id, date);
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
async function load() {
|
| 91 |
+
const stats = document.getElementById('channelStats');
|
| 92 |
+
const list = document.getElementById('channelList');
|
| 93 |
+
|
| 94 |
+
if (stats) stats.textContent = '加载中...';
|
| 95 |
+
if (list) list.innerHTML = '<div class="skeleton skeleton-card"></div>'.repeat(4);
|
| 96 |
+
|
| 97 |
+
try {
|
| 98 |
+
const res = await fetch(`${API}/api/list`);
|
| 99 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 100 |
+
|
| 101 |
+
const data = await res.json();
|
| 102 |
+
if (!data.success) throw new Error(data.error || '加载失败');
|
| 103 |
+
|
| 104 |
+
channels = data.channels || [];
|
| 105 |
+
|
| 106 |
+
if (stats) {
|
| 107 |
+
const cacheIcon = data.cached ? '✅' : '🔄';
|
| 108 |
+
stats.innerHTML = `📊 共 <strong style="color: var(--primary);">${data.count || 0}</strong> 个频道 ${cacheIcon}`;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
renderBatch(channels);
|
| 112 |
+
|
| 113 |
+
if (window.MediaGatewayUtils) {
|
| 114 |
+
window.MediaGatewayUtils.showNotification(`加载 ${data.count} 个频道`, 'success');
|
| 115 |
+
}
|
| 116 |
+
} catch (e) {
|
| 117 |
+
if (list) list.innerHTML = `<div style="grid-column: 1/-1; text-align: center; padding: 40px; color: var(--danger);">❌ 加载失败: ${e.message}</div>`;
|
| 118 |
+
if (stats) stats.innerHTML = '<strong style="color: var(--danger);">❌ 加载失败</strong>';
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function search(e) {
|
| 123 |
+
clearTimeout(searchTimeout);
|
| 124 |
+
searchTimeout = setTimeout(() => {
|
| 125 |
+
const q = e.target.value.toLowerCase().trim();
|
| 126 |
+
|
| 127 |
+
if (!q) {
|
| 128 |
+
renderBatch(channels);
|
| 129 |
+
return;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
const filtered = channels.filter(ch =>
|
| 133 |
+
ch.name.toLowerCase().includes(q) ||
|
| 134 |
+
String(ch.no).includes(q)
|
| 135 |
+
);
|
| 136 |
+
|
| 137 |
+
renderBatch(filtered);
|
| 138 |
+
}, 300);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
window.initChannelsPage = function() {
|
| 142 |
+
load();
|
| 143 |
+
|
| 144 |
+
const searchInput = document.getElementById('channelSearch');
|
| 145 |
+
const refreshBtn = document.getElementById('refreshChannels');
|
| 146 |
+
|
| 147 |
+
if (searchInput) searchInput.addEventListener('input', search);
|
| 148 |
+
if (refreshBtn) refreshBtn.addEventListener('click', load);
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
setTimeout(window.initChannelsPage, 0);
|
| 152 |
+
})();
|
| 153 |
+
</script>
|
static/templates/epg.html
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/templates/player.html
ADDED
|
@@ -0,0 +1,2566 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div class="mdl-player-page">
|
| 2 |
+
<div class="mdl-player-layout">
|
| 3 |
+
<div class="mdl-player-main">
|
| 4 |
+
<div class="mdl-video-wrapper">
|
| 5 |
+
<div id="dplayer"></div>
|
| 6 |
+
|
| 7 |
+
<div class="player-placeholder" id="playerPlaceholder">
|
| 8 |
+
<div class="placeholder-icon">📺</div>
|
| 9 |
+
<div class="placeholder-text">点击频道开始播放</div>
|
| 10 |
+
</div>
|
| 11 |
+
|
| 12 |
+
<div class="loading-indicator hidden" id="loadingIndicator">
|
| 13 |
+
<div class="loading-spinner"></div>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
<div class="live-badge" id="liveBadge" style="display: none;">
|
| 17 |
+
<span class="live-dot"></span>
|
| 18 |
+
<span class="live-text">LIVE</span>
|
| 19 |
+
</div>
|
| 20 |
+
</div>
|
| 21 |
+
|
| 22 |
+
<div class="mdl-info-panel" id="infoPanel">
|
| 23 |
+
<div class="panel-header" onclick="toggleInfoPanel()">
|
| 24 |
+
<div class="header-left">
|
| 25 |
+
<h3 id="currentProgramTitle">选择频道以查看节目信息</h3>
|
| 26 |
+
</div>
|
| 27 |
+
<button class="collapse-btn" title="折叠/展开">
|
| 28 |
+
<span class="collapse-icon">▼</span>
|
| 29 |
+
</button>
|
| 30 |
+
</div>
|
| 31 |
+
<div class="panel-body">
|
| 32 |
+
<div class="time-info">
|
| 33 |
+
<span id="programTime">--:-- ~ --:--</span>
|
| 34 |
+
<span id="currentTime" class="current-time">--:-- (JST)</span>
|
| 35 |
+
</div>
|
| 36 |
+
<div class="progress-bar">
|
| 37 |
+
<div class="progress-fill" id="programProgress"></div>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<div class="mdl-record-panel" id="recordPanel">
|
| 43 |
+
<div class="record-header">
|
| 44 |
+
<h4>🎬 录制</h4>
|
| 45 |
+
<button id="recordBtn" class="record-btn-mini" disabled>
|
| 46 |
+
<span class="btn-icon">⏺</span>
|
| 47 |
+
<span class="btn-text">开始</span>
|
| 48 |
+
</button>
|
| 49 |
+
</div>
|
| 50 |
+
<div id="recordStatus" class="record-status-mini"></div>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<button class="sidebar-expand-btn" id="expandBtn" style="display: none;" title="展开频道列表">
|
| 55 |
+
▶ 频道
|
| 56 |
+
</button>
|
| 57 |
+
|
| 58 |
+
<div class="mdl-channel-sidebar" id="channelSidebar">
|
| 59 |
+
<div class="sidebar-header">
|
| 60 |
+
<h3>📺 频道列表</h3>
|
| 61 |
+
<button class="sidebar-collapse-btn" id="collapseBtn" title="收起侧边栏">
|
| 62 |
+
◀
|
| 63 |
+
</button>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<div class="category-tabs">
|
| 67 |
+
<button class="category-tab active" data-category="all">全部</button>
|
| 68 |
+
<button class="category-tab" data-category="favorites">⭐ 收藏</button>
|
| 69 |
+
<button class="category-tab" data-category="関東">🗼 関東</button>
|
| 70 |
+
<button class="category-tab" data-category="関西">🏯 関西</button>
|
| 71 |
+
<button class="category-tab" data-category="BS">🛰 BS</button>
|
| 72 |
+
<button class="category-tab" data-category="CS">📡 CS</button>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<div class="search-box">
|
| 76 |
+
<input
|
| 77 |
+
type="text"
|
| 78 |
+
id="channelSearch"
|
| 79 |
+
placeholder="🔍 搜索频道..."
|
| 80 |
+
class="search-input"
|
| 81 |
+
>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div class="channel-list" id="channelList">
|
| 85 |
+
<div class="loading-state">
|
| 86 |
+
<div class="spinner"></div>
|
| 87 |
+
<p>加载频道中...</p>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.4.12/dist/hls.min.js"></script>
|
| 95 |
+
<script src="https://cdn.jsdelivr.net/npm/dplayer@1.27.1/dist/DPlayer.min.js"></script>
|
| 96 |
+
|
| 97 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/dplayer@1.27.1/dist/DPlayer.min.css">
|
| 98 |
+
|
| 99 |
+
<script src="/static/js/user-data-sync.js"></script>
|
| 100 |
+
|
| 101 |
+
<style>
|
| 102 |
+
:root {
|
| 103 |
+
--gradient-main: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 104 |
+
--gradient-light: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
| 105 |
+
--gradient-bg: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
| 106 |
+
--color-primary: #667eea;
|
| 107 |
+
--color-secondary: #764ba2;
|
| 108 |
+
--shadow-soft: 0 8px 32px rgba(102, 126, 234, 0.12);
|
| 109 |
+
--shadow-hover: 0 12px 48px rgba(102, 126, 234, 0.2);
|
| 110 |
+
--glass-bg: rgba(255, 255, 255, 0.25);
|
| 111 |
+
--glass-border: rgba(255, 255, 255, 0.18);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.mdl-player-page {
|
| 115 |
+
margin: 0;
|
| 116 |
+
padding: 0;
|
| 117 |
+
position: relative;
|
| 118 |
+
overflow: hidden;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.mdl-player-layout {
|
| 122 |
+
display: grid;
|
| 123 |
+
grid-template-columns: 1fr 550px;
|
| 124 |
+
gap: 0;
|
| 125 |
+
min-height: calc(100vh - 200px);
|
| 126 |
+
background: var(--gradient-bg);
|
| 127 |
+
transition: grid-template-columns 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
| 128 |
+
position: relative;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.mdl-player-layout.sidebar-hidden {
|
| 132 |
+
grid-template-columns: 1fr 0;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.mdl-player-main {
|
| 136 |
+
background: var(--glass-bg);
|
| 137 |
+
backdrop-filter: blur(20px);
|
| 138 |
+
-webkit-backdrop-filter: blur(20px);
|
| 139 |
+
border: 1px solid var(--glass-border);
|
| 140 |
+
display: flex;
|
| 141 |
+
flex-direction: column;
|
| 142 |
+
padding: 20px;
|
| 143 |
+
gap: 20px;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.mdl-video-wrapper {
|
| 147 |
+
position: relative;
|
| 148 |
+
width: 100%;
|
| 149 |
+
aspect-ratio: 16 / 9;
|
| 150 |
+
max-height: 65vh;
|
| 151 |
+
background: #000;
|
| 152 |
+
border-radius: 12px;
|
| 153 |
+
overflow: hidden;
|
| 154 |
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3),
|
| 155 |
+
0 0 0 1px rgba(102, 126, 234, 0.15);
|
| 156 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.mdl-video-wrapper:hover {
|
| 160 |
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4),
|
| 161 |
+
0 0 0 1px rgba(102, 126, 234, 0.3);
|
| 162 |
+
}
|
| 163 |
+
.custom-screenshot-btn {
|
| 164 |
+
position: relative;
|
| 165 |
+
top: 1px;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
#dplayer {
|
| 169 |
+
width: 100%;
|
| 170 |
+
height: 100%;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
#dplayer .dplayer-mask {
|
| 174 |
+
display: none !important;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
#dplayer .dplayer-logo {
|
| 178 |
+
display: none !important;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
#dplayer .dplayer-notice {
|
| 182 |
+
display: none !important;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.player-placeholder {
|
| 186 |
+
position: absolute;
|
| 187 |
+
top: 0;
|
| 188 |
+
left: 0;
|
| 189 |
+
width: 100%;
|
| 190 |
+
height: 100%;
|
| 191 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 192 |
+
display: flex;
|
| 193 |
+
flex-direction: column;
|
| 194 |
+
align-items: center;
|
| 195 |
+
justify-content: center;
|
| 196 |
+
gap: 20px;
|
| 197 |
+
z-index: 998;
|
| 198 |
+
transition: opacity 0.3s ease, visibility 0.3s ease;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.player-placeholder.hidden {
|
| 202 |
+
opacity: 0;
|
| 203 |
+
visibility: hidden;
|
| 204 |
+
pointer-events: none;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.placeholder-icon {
|
| 208 |
+
font-size: 80px;
|
| 209 |
+
animation: floatIcon 3s ease-in-out infinite;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
@keyframes floatIcon {
|
| 213 |
+
0%, 100% { transform: translateY(0px); }
|
| 214 |
+
50% { transform: translateY(-20px); }
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.placeholder-text {
|
| 218 |
+
color: white;
|
| 219 |
+
font-size: 24px;
|
| 220 |
+
font-weight: 700;
|
| 221 |
+
letter-spacing: 2px;
|
| 222 |
+
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.loading-indicator {
|
| 226 |
+
position: absolute;
|
| 227 |
+
top: 0;
|
| 228 |
+
left: 0;
|
| 229 |
+
width: 100%;
|
| 230 |
+
height: 100%;
|
| 231 |
+
background: transparent;
|
| 232 |
+
display: flex;
|
| 233 |
+
align-items: center;
|
| 234 |
+
justify-content: center;
|
| 235 |
+
z-index: 100;
|
| 236 |
+
transition: opacity 0.3s ease, visibility 0.3s ease;
|
| 237 |
+
pointer-events: none;
|
| 238 |
+
}
|
| 239 |
+
.loading-indicator.hidden {
|
| 240 |
+
opacity: 0;
|
| 241 |
+
visibility: hidden;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.loading-spinner {
|
| 245 |
+
width: 60px;
|
| 246 |
+
height: 60px;
|
| 247 |
+
border: 4px solid rgba(102, 126, 234, 0.2) !important;
|
| 248 |
+
border-top: 4px solid #667eea !important;
|
| 249 |
+
border-radius: 50% !important;
|
| 250 |
+
animation: spinLoader 0.8s linear infinite !important;
|
| 251 |
+
display: block !important;
|
| 252 |
+
}
|
| 253 |
+
@keyframes spinLoader {
|
| 254 |
+
from { transform: rotate(0deg); }
|
| 255 |
+
to { transform: rotate(360deg); }
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.live-badge {
|
| 259 |
+
position: absolute;
|
| 260 |
+
top: 16px;
|
| 261 |
+
left: 16px;
|
| 262 |
+
z-index: 1000;
|
| 263 |
+
display: flex;
|
| 264 |
+
align-items: center;
|
| 265 |
+
gap: 8px;
|
| 266 |
+
background: linear-gradient(135deg, rgba(239, 68, 68, 0.95) 0%, rgba(220, 38, 38, 0.95) 100%);
|
| 267 |
+
padding: 8px 16px;
|
| 268 |
+
border-radius: 20px;
|
| 269 |
+
box-shadow: 0 2px 12px rgba(239, 68, 68, 0.4);
|
| 270 |
+
backdrop-filter: blur(10px);
|
| 271 |
+
-webkit-backdrop-filter: blur(10px);
|
| 272 |
+
border: 1.5px solid rgba(255, 255, 255, 0.3);
|
| 273 |
+
font-size: 12px;
|
| 274 |
+
font-weight: 800;
|
| 275 |
+
color: white;
|
| 276 |
+
text-transform: uppercase;
|
| 277 |
+
letter-spacing: 1.5px;
|
| 278 |
+
pointer-events: none;
|
| 279 |
+
transition: opacity 0.3s ease, transform 0.3s ease;
|
| 280 |
+
animation: livePulse 2s ease-in-out infinite;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.live-badge.hidden {
|
| 284 |
+
opacity: 0;
|
| 285 |
+
transform: scale(0.8);
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.live-dot {
|
| 289 |
+
width: 10px;
|
| 290 |
+
height: 10px;
|
| 291 |
+
background: white;
|
| 292 |
+
border-radius: 50%;
|
| 293 |
+
animation: liveDotBlink 1.5s ease-in-out infinite;
|
| 294 |
+
box-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.live-text {
|
| 298 |
+
font-size: 12px;
|
| 299 |
+
font-weight: 800;
|
| 300 |
+
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
| 301 |
+
animation: liveTextGlow 2s ease-in-out infinite;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
@keyframes liveDotBlink {
|
| 305 |
+
0%, 100% {
|
| 306 |
+
opacity: 1;
|
| 307 |
+
transform: scale(1);
|
| 308 |
+
}
|
| 309 |
+
50% {
|
| 310 |
+
opacity: 0.3;
|
| 311 |
+
transform: scale(0.8);
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
@keyframes liveTextGlow {
|
| 316 |
+
0%, 100% {
|
| 317 |
+
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
| 318 |
+
}
|
| 319 |
+
50% {
|
| 320 |
+
text-shadow: 0 0 12px rgba(255, 255, 255, 0.8),
|
| 321 |
+
0 2px 4px rgba(0, 0, 0, 0.3);
|
| 322 |
+
}
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
@keyframes livePulse {
|
| 326 |
+
0%, 100% {
|
| 327 |
+
transform: scale(1);
|
| 328 |
+
box-shadow: 0 2px 12px rgba(239, 68, 68, 0.4);
|
| 329 |
+
}
|
| 330 |
+
50% {
|
| 331 |
+
transform: scale(1.05);
|
| 332 |
+
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.6);
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.mdl-info-panel {
|
| 337 |
+
background: var(--glass-bg);
|
| 338 |
+
backdrop-filter: blur(24px);
|
| 339 |
+
-webkit-backdrop-filter: blur(24px);
|
| 340 |
+
border: 1px solid var(--glass-border);
|
| 341 |
+
border-radius: 20px;
|
| 342 |
+
box-shadow: var(--shadow-soft);
|
| 343 |
+
overflow: hidden;
|
| 344 |
+
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.mdl-info-panel:hover {
|
| 348 |
+
box-shadow: var(--shadow-hover);
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.mdl-info-panel .panel-header {
|
| 352 |
+
padding: 20px 24px;
|
| 353 |
+
cursor: pointer;
|
| 354 |
+
display: flex;
|
| 355 |
+
justify-content: space-between;
|
| 356 |
+
align-items: center;
|
| 357 |
+
user-select: none;
|
| 358 |
+
transition: all 0.3s ease;
|
| 359 |
+
border-bottom: 1px solid var(--glass-border);
|
| 360 |
+
background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
.mdl-info-panel .panel-header:hover {
|
| 364 |
+
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
.header-left {
|
| 368 |
+
display: flex;
|
| 369 |
+
align-items: center;
|
| 370 |
+
gap: 16px;
|
| 371 |
+
flex: 1;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.header-left h3 {
|
| 375 |
+
margin: 0;
|
| 376 |
+
font-size: 19px;
|
| 377 |
+
font-weight: 700;
|
| 378 |
+
color: var(--color-primary);
|
| 379 |
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.collapse-btn {
|
| 383 |
+
background: rgba(102, 126, 234, 0.12);
|
| 384 |
+
border: none;
|
| 385 |
+
color: var(--color-primary);
|
| 386 |
+
cursor: pointer;
|
| 387 |
+
padding: 10px;
|
| 388 |
+
border-radius: 10px;
|
| 389 |
+
transition: all 0.3s ease;
|
| 390 |
+
display: flex;
|
| 391 |
+
align-items: center;
|
| 392 |
+
justify-content: center;
|
| 393 |
+
backdrop-filter: blur(10px);
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
.collapse-btn:hover {
|
| 397 |
+
background: rgba(102, 126, 234, 0.25);
|
| 398 |
+
transform: scale(1.1) rotate(180deg);
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
.collapse-icon {
|
| 402 |
+
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
| 403 |
+
font-size: 16px;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.mdl-info-panel.collapsed .collapse-icon {
|
| 407 |
+
transform: rotate(-90deg);
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
.panel-body {
|
| 411 |
+
max-height: 500px;
|
| 412 |
+
overflow: hidden;
|
| 413 |
+
transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
| 414 |
+
padding 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
| 415 |
+
opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
| 416 |
+
opacity: 1;
|
| 417 |
+
padding: 24px;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
.mdl-info-panel.collapsed .panel-body {
|
| 421 |
+
max-height: 0;
|
| 422 |
+
padding: 0 24px;
|
| 423 |
+
opacity: 0;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
.time-info {
|
| 427 |
+
display: flex;
|
| 428 |
+
justify-content: space-between;
|
| 429 |
+
align-items: center;
|
| 430 |
+
font-size: 16px;
|
| 431 |
+
font-family: 'Courier New', monospace;
|
| 432 |
+
font-weight: 700;
|
| 433 |
+
color: var(--color-primary);
|
| 434 |
+
margin-bottom: 18px;
|
| 435 |
+
padding: 12px 16px;
|
| 436 |
+
background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);
|
| 437 |
+
border-radius: 12px;
|
| 438 |
+
border: 1px solid rgba(102, 126, 234, 0.15);
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
.current-time {
|
| 442 |
+
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%);
|
| 443 |
+
padding: 10px 18px;
|
| 444 |
+
border-radius: 14px;
|
| 445 |
+
backdrop-filter: blur(10px);
|
| 446 |
+
box-shadow: 0 3px 10px rgba(102, 126, 234, 0.15);
|
| 447 |
+
border: 1.5px solid rgba(102, 126, 234, 0.25);
|
| 448 |
+
font-weight: 800;
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
.progress-bar {
|
| 452 |
+
width: 100%;
|
| 453 |
+
height: 12px;
|
| 454 |
+
background: rgba(102, 126, 234, 0.12);
|
| 455 |
+
border-radius: 8px;
|
| 456 |
+
overflow: hidden;
|
| 457 |
+
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.1);
|
| 458 |
+
border: 1.5px solid rgba(102, 126, 234, 0.15);
|
| 459 |
+
position: relative;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
.progress-fill {
|
| 463 |
+
height: 100%;
|
| 464 |
+
background: linear-gradient(90deg, #667eea 0%, #764ba2 50%, #667eea 100%);
|
| 465 |
+
background-size: 200% 100%;
|
| 466 |
+
width: 0%;
|
| 467 |
+
transition: width 2s linear;
|
| 468 |
+
border-radius: 8px;
|
| 469 |
+
box-shadow: 0 0 12px rgba(102, 126, 234, 0.6),
|
| 470 |
+
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
| 471 |
+
position: relative;
|
| 472 |
+
overflow: hidden;
|
| 473 |
+
animation: progressShimmer 3s linear infinite;
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
@keyframes progressShimmer {
|
| 477 |
+
0% { background-position: 200% 0; }
|
| 478 |
+
100% { background-position: -200% 0; }
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
.progress-fill::after {
|
| 482 |
+
content: '';
|
| 483 |
+
position: absolute;
|
| 484 |
+
top: 0;
|
| 485 |
+
left: 0;
|
| 486 |
+
bottom: 0;
|
| 487 |
+
right: 0;
|
| 488 |
+
background: linear-gradient(90deg,
|
| 489 |
+
transparent,
|
| 490 |
+
rgba(255, 255, 255, 0.3),
|
| 491 |
+
transparent
|
| 492 |
+
);
|
| 493 |
+
animation: shimmer 2s infinite;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
@keyframes shimmer {
|
| 497 |
+
0% { transform: translateX(-100%); }
|
| 498 |
+
100% { transform: translateX(100%); }
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
.mdl-record-panel {
|
| 502 |
+
background: var(--glass-bg);
|
| 503 |
+
backdrop-filter: blur(24px);
|
| 504 |
+
-webkit-backdrop-filter: blur(24px);
|
| 505 |
+
border: 1px solid var(--glass-border);
|
| 506 |
+
border-radius: 20px;
|
| 507 |
+
box-shadow: var(--shadow-soft);
|
| 508 |
+
padding: 20px;
|
| 509 |
+
transition: all 0.3s ease;
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
.mdl-record-panel:hover {
|
| 513 |
+
box-shadow: var(--shadow-hover);
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
.record-header {
|
| 517 |
+
display: flex;
|
| 518 |
+
justify-content: space-between;
|
| 519 |
+
align-items: center;
|
| 520 |
+
gap: 16px;
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
.record-header h4 {
|
| 524 |
+
margin: 0;
|
| 525 |
+
font-size: 16px;
|
| 526 |
+
font-weight: 700;
|
| 527 |
+
color: var(--color-primary);
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
.record-btn-mini {
|
| 531 |
+
padding: 10px 20px;
|
| 532 |
+
background: var(--gradient-main);
|
| 533 |
+
color: white;
|
| 534 |
+
border: none;
|
| 535 |
+
border-radius: 12px;
|
| 536 |
+
cursor: pointer;
|
| 537 |
+
font-size: 14px;
|
| 538 |
+
font-weight: 600;
|
| 539 |
+
display: flex;
|
| 540 |
+
align-items: center;
|
| 541 |
+
gap: 8px;
|
| 542 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 543 |
+
box-shadow: var(--shadow-soft);
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
.record-btn-mini:hover:not(:disabled) {
|
| 547 |
+
transform: translateY(-3px) scale(1.05);
|
| 548 |
+
box-shadow: var(--shadow-hover);
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
.record-btn-mini:disabled {
|
| 552 |
+
background: linear-gradient(135deg, #cbd5e0 0%, #a0aec0 100%);
|
| 553 |
+
cursor: not-allowed;
|
| 554 |
+
opacity: 0.6;
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
.record-status-mini {
|
| 558 |
+
display: none;
|
| 559 |
+
padding: 12px;
|
| 560 |
+
border-radius: 12px;
|
| 561 |
+
margin-top: 12px;
|
| 562 |
+
font-size: 13px;
|
| 563 |
+
text-align: center;
|
| 564 |
+
font-weight: 600;
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
.record-status-mini.show {
|
| 568 |
+
display: block;
|
| 569 |
+
animation: statusSlideIn 0.3s ease;
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
@keyframes statusSlideIn {
|
| 573 |
+
from { opacity: 0; transform: translateY(-10px); }
|
| 574 |
+
to { opacity: 1; transform: translateY(0); }
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
.record-status-mini.recording {
|
| 578 |
+
background: linear-gradient(135deg, rgba(251, 191, 36, 0.2) 0%, rgba(245, 158, 11, 0.2) 100%);
|
| 579 |
+
color: #f59e0b;
|
| 580 |
+
border: 1.5px solid rgba(251, 191, 36, 0.3);
|
| 581 |
+
backdrop-filter: blur(10px);
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
.record-status-mini.success {
|
| 585 |
+
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(22, 163, 74, 0.2) 100%);
|
| 586 |
+
color: #22c55e;
|
| 587 |
+
border: 1.5px solid rgba(34, 197, 94, 0.3);
|
| 588 |
+
backdrop-filter: blur(10px);
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
.record-status-mini.error {
|
| 592 |
+
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2) 0%, rgba(220, 38, 38, 0.2) 100%);
|
| 593 |
+
color: #ef4444;
|
| 594 |
+
border: 1.5px solid rgba(239, 68, 68, 0.3);
|
| 595 |
+
backdrop-filter: blur(10px);
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
.sidebar-expand-btn {
|
| 599 |
+
position: fixed;
|
| 600 |
+
right: 20px;
|
| 601 |
+
top: 50%;
|
| 602 |
+
transform: translateY(-50%);
|
| 603 |
+
background: var(--gradient-main);
|
| 604 |
+
color: white;
|
| 605 |
+
border: none;
|
| 606 |
+
padding: 14px 18px;
|
| 607 |
+
border-radius: 12px 0 0 12px;
|
| 608 |
+
cursor: pointer;
|
| 609 |
+
font-size: 15px;
|
| 610 |
+
font-weight: 700;
|
| 611 |
+
box-shadow: var(--shadow-soft);
|
| 612 |
+
z-index: 1000;
|
| 613 |
+
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
| 614 |
+
display: none;
|
| 615 |
+
backdrop-filter: blur(10px);
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
.sidebar-expand-btn:hover {
|
| 619 |
+
padding-right: 24px;
|
| 620 |
+
box-shadow: var(--shadow-hover);
|
| 621 |
+
transform: translateY(-50%) translateX(-8px);
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
.mdl-player-layout.sidebar-hidden .sidebar-expand-btn {
|
| 625 |
+
display: block;
|
| 626 |
+
animation: slideInRight 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
@keyframes slideInRight {
|
| 630 |
+
from {
|
| 631 |
+
opacity: 0;
|
| 632 |
+
transform: translateY(-50%) translateX(100%);
|
| 633 |
+
}
|
| 634 |
+
to {
|
| 635 |
+
opacity: 1;
|
| 636 |
+
transform: translateY(-50%) translateX(0);
|
| 637 |
+
}
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
.mdl-channel-sidebar {
|
| 641 |
+
background: var(--glass-bg);
|
| 642 |
+
backdrop-filter: blur(24px);
|
| 643 |
+
-webkit-backdrop-filter: blur(24px);
|
| 644 |
+
border-left: 1px solid var(--glass-border);
|
| 645 |
+
display: flex;
|
| 646 |
+
flex-direction: column;
|
| 647 |
+
max-height: calc(100vh - 200px);
|
| 648 |
+
overflow: hidden;
|
| 649 |
+
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
| 650 |
+
box-shadow: -8px 0 32px rgba(102, 126, 234, 0.1);
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
.mdl-player-layout.sidebar-hidden .mdl-channel-sidebar {
|
| 654 |
+
transform: translateX(100%);
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
.sidebar-header {
|
| 658 |
+
display: flex;
|
| 659 |
+
justify-content: space-between;
|
| 660 |
+
align-items: center;
|
| 661 |
+
padding: 20px 24px;
|
| 662 |
+
background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);
|
| 663 |
+
border-bottom: 1px solid var(--glass-border);
|
| 664 |
+
backdrop-filter: blur(10px);
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
.sidebar-header h3 {
|
| 668 |
+
margin: 0;
|
| 669 |
+
font-size: 19px;
|
| 670 |
+
font-weight: 700;
|
| 671 |
+
color: var(--color-primary);
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
.sidebar-collapse-btn {
|
| 675 |
+
background: rgba(102, 126, 234, 0.15);
|
| 676 |
+
border: none;
|
| 677 |
+
color: var(--color-primary);
|
| 678 |
+
font-size: 20px;
|
| 679 |
+
cursor: pointer;
|
| 680 |
+
padding: 10px;
|
| 681 |
+
border-radius: 10px;
|
| 682 |
+
transition: all 0.3s ease;
|
| 683 |
+
backdrop-filter: blur(10px);
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
.sidebar-collapse-btn:hover {
|
| 687 |
+
background: rgba(102, 126, 234, 0.25);
|
| 688 |
+
transform: scale(1.15);
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
.category-tabs {
|
| 692 |
+
display: grid;
|
| 693 |
+
grid-template-columns: repeat(6, 1fr);
|
| 694 |
+
gap: 2px;
|
| 695 |
+
background: rgba(102, 126, 234, 0.1);
|
| 696 |
+
border-bottom: 1px solid var(--glass-border);
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
.category-tab {
|
| 700 |
+
background: rgba(255, 255, 255, 0.5);
|
| 701 |
+
backdrop-filter: blur(10px);
|
| 702 |
+
border: none;
|
| 703 |
+
padding: 14px 10px;
|
| 704 |
+
cursor: pointer;
|
| 705 |
+
font-size: 13px;
|
| 706 |
+
font-weight: 600;
|
| 707 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 708 |
+
color: #4a5568;
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
.category-tab:hover {
|
| 712 |
+
background: var(--gradient-light);
|
| 713 |
+
transform: translateY(-2px);
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
.category-tab.active {
|
| 717 |
+
background: var(--gradient-main);
|
| 718 |
+
color: white;
|
| 719 |
+
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
| 720 |
+
transform: translateY(-2px);
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
.search-box {
|
| 724 |
+
padding: 16px;
|
| 725 |
+
background: rgba(255, 255, 255, 0.5);
|
| 726 |
+
backdrop-filter: blur(10px);
|
| 727 |
+
border-bottom: 1px solid var(--glass-border);
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
.search-input {
|
| 731 |
+
width: 100%;
|
| 732 |
+
padding: 12px 16px;
|
| 733 |
+
border: 2px solid rgba(102, 126, 234, 0.2);
|
| 734 |
+
border-radius: 12px;
|
| 735 |
+
font-size: 14px;
|
| 736 |
+
outline: none;
|
| 737 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 738 |
+
background: rgba(255, 255, 255, 0.8);
|
| 739 |
+
backdrop-filter: blur(10px);
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
.search-input:focus {
|
| 743 |
+
border-color: var(--color-primary);
|
| 744 |
+
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.15);
|
| 745 |
+
transform: translateY(-2px);
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
.channel-list {
|
| 749 |
+
flex: 1;
|
| 750 |
+
overflow-y: auto;
|
| 751 |
+
background: rgba(255, 255, 255, 0.3);
|
| 752 |
+
backdrop-filter: blur(10px);
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
.loading-state {
|
| 756 |
+
padding: 80px 20px;
|
| 757 |
+
text-align: center;
|
| 758 |
+
color: #718096;
|
| 759 |
+
}
|
| 760 |
+
|
| 761 |
+
.spinner {
|
| 762 |
+
width: 48px;
|
| 763 |
+
height: 48px;
|
| 764 |
+
border: 4px solid rgba(102, 126, 234, 0.2);
|
| 765 |
+
border-top: 4px solid var(--color-primary);
|
| 766 |
+
border-radius: 50%;
|
| 767 |
+
animation: spin 0.8s linear infinite;
|
| 768 |
+
margin: 0 auto 20px;
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
@keyframes spin {
|
| 772 |
+
0% { transform: rotate(0deg); }
|
| 773 |
+
100% { transform: rotate(360deg); }
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
.channel-item {
|
| 777 |
+
padding: 14px 16px;
|
| 778 |
+
padding-right: 55px;
|
| 779 |
+
border-bottom: 1px solid var(--glass-border);
|
| 780 |
+
cursor: pointer;
|
| 781 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 782 |
+
background: rgba(255, 255, 255, 0.5);
|
| 783 |
+
backdrop-filter: blur(10px);
|
| 784 |
+
position: relative;
|
| 785 |
+
min-height: 80px;
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
.channel-item:hover {
|
| 789 |
+
background: var(--gradient-light);
|
| 790 |
+
transform: translateX(4px);
|
| 791 |
+
box-shadow: 0 3px 10px rgba(102, 126, 234, 0.12);
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
.channel-item.active {
|
| 795 |
+
background: var(--gradient-main);
|
| 796 |
+
color: white;
|
| 797 |
+
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.35);
|
| 798 |
+
transform: translateX(4px);
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
.channel-item-content {
|
| 802 |
+
display: flex;
|
| 803 |
+
align-items: flex-start;
|
| 804 |
+
gap: 12px;
|
| 805 |
+
position: relative;
|
| 806 |
+
width: 100%;
|
| 807 |
+
}
|
| 808 |
+
|
| 809 |
+
.favorite-btn {
|
| 810 |
+
position: absolute;
|
| 811 |
+
right: 6px;
|
| 812 |
+
top: 50%;
|
| 813 |
+
transform: translateY(-50%);
|
| 814 |
+
background: rgba(255, 255, 255, 0.95);
|
| 815 |
+
border: 1.5px solid rgba(102, 126, 234, 0.2);
|
| 816 |
+
color: #cbd5e1;
|
| 817 |
+
font-size: 16px;
|
| 818 |
+
width: 32px;
|
| 819 |
+
height: 32px;
|
| 820 |
+
border-radius: 50%;
|
| 821 |
+
cursor: pointer;
|
| 822 |
+
display: flex;
|
| 823 |
+
align-items: center;
|
| 824 |
+
justify-content: center;
|
| 825 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 826 |
+
backdrop-filter: blur(10px);
|
| 827 |
+
z-index: 10;
|
| 828 |
+
opacity: 0;
|
| 829 |
+
pointer-events: none;
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
.channel-item:hover .favorite-btn {
|
| 833 |
+
opacity: 1;
|
| 834 |
+
pointer-events: auto;
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
.favorite-btn.active {
|
| 838 |
+
opacity: 1;
|
| 839 |
+
pointer-events: auto;
|
| 840 |
+
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
| 841 |
+
color: white;
|
| 842 |
+
border-color: #fbbf24;
|
| 843 |
+
box-shadow: 0 2px 8px rgba(251, 191, 36, 0.3);
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
.favorite-btn:hover {
|
| 847 |
+
transform: translateY(-50%) scale(1.15);
|
| 848 |
+
border-color: var(--color-primary);
|
| 849 |
+
box-shadow: 0 3px 10px rgba(102, 126, 234, 0.35);
|
| 850 |
+
}
|
| 851 |
+
|
| 852 |
+
.favorite-btn.active:hover {
|
| 853 |
+
transform: translateY(-50%) scale(1.15);
|
| 854 |
+
box-shadow: 0 3px 12px rgba(251, 191, 36, 0.45);
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
.channel-item.active .favorite-btn {
|
| 858 |
+
background: rgba(255, 255, 255, 0.98);
|
| 859 |
+
border-color: rgba(255, 255, 255, 0.6);
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
.channel-item.active .favorite-btn.active {
|
| 863 |
+
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
| 864 |
+
border-color: #fbbf24;
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
.channel-logo {
|
| 868 |
+
width: 40px;
|
| 869 |
+
height: 40px;
|
| 870 |
+
background: rgba(102, 126, 234, 0.12);
|
| 871 |
+
border-radius: 10px;
|
| 872 |
+
display: flex;
|
| 873 |
+
align-items: center;
|
| 874 |
+
justify-content: center;
|
| 875 |
+
font-size: 20px;
|
| 876 |
+
flex-shrink: 0;
|
| 877 |
+
box-shadow: 0 2px 6px rgba(102, 126, 234, 0.15);
|
| 878 |
+
transition: all 0.3s ease;
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
+
.channel-item:hover .channel-logo {
|
| 882 |
+
transform: scale(1.1) rotate(5deg);
|
| 883 |
+
}
|
| 884 |
+
|
| 885 |
+
.channel-item.active .channel-logo {
|
| 886 |
+
background: rgba(255, 255, 255, 0.3);
|
| 887 |
+
backdrop-filter: blur(10px);
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
.channel-details {
|
| 891 |
+
flex: 1;
|
| 892 |
+
min-width: 0;
|
| 893 |
+
display: flex;
|
| 894 |
+
flex-direction: column;
|
| 895 |
+
gap: 6px;
|
| 896 |
+
}
|
| 897 |
+
|
| 898 |
+
.program-title {
|
| 899 |
+
font-weight: 600;
|
| 900 |
+
font-size: 13px;
|
| 901 |
+
line-height: 1.4;
|
| 902 |
+
color: var(--color-primary);
|
| 903 |
+
word-wrap: break-word;
|
| 904 |
+
overflow-wrap: break-word;
|
| 905 |
+
margin: 0;
|
| 906 |
+
}
|
| 907 |
+
|
| 908 |
+
.channel-item.active .program-title {
|
| 909 |
+
color: white;
|
| 910 |
+
font-weight: 700;
|
| 911 |
+
}
|
| 912 |
+
|
| 913 |
+
.channel-meta {
|
| 914 |
+
display: flex;
|
| 915 |
+
align-items: center;
|
| 916 |
+
gap: 8px;
|
| 917 |
+
flex-wrap: wrap;
|
| 918 |
+
font-size: 11px;
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
.program-time {
|
| 922 |
+
color: #64748b;
|
| 923 |
+
font-weight: 500;
|
| 924 |
+
font-family: 'Courier New', monospace;
|
| 925 |
+
display: flex;
|
| 926 |
+
align-items: center;
|
| 927 |
+
gap: 3px;
|
| 928 |
+
white-space: nowrap;
|
| 929 |
+
}
|
| 930 |
+
|
| 931 |
+
.channel-item.active .program-time {
|
| 932 |
+
color: rgba(255, 255, 255, 0.85);
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
.channel-name-badge {
|
| 936 |
+
display: inline-flex;
|
| 937 |
+
align-items: center;
|
| 938 |
+
padding: 2px 10px;
|
| 939 |
+
background: rgba(102, 126, 234, 0.12);
|
| 940 |
+
color: var(--color-primary);
|
| 941 |
+
border-radius: 10px;
|
| 942 |
+
font-weight: 600;
|
| 943 |
+
font-size: 10px;
|
| 944 |
+
border: 1px solid rgba(102, 126, 234, 0.15);
|
| 945 |
+
backdrop-filter: blur(10px);
|
| 946 |
+
white-space: nowrap;
|
| 947 |
+
}
|
| 948 |
+
|
| 949 |
+
.channel-item.active .channel-name-badge {
|
| 950 |
+
background: rgba(255, 255, 255, 0.2);
|
| 951 |
+
color: white;
|
| 952 |
+
border-color: rgba(255, 255, 255, 0.25);
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
+
.channel-epg-progress {
|
| 956 |
+
width: 100%;
|
| 957 |
+
height: 4px;
|
| 958 |
+
background: rgba(102, 126, 234, 0.2);
|
| 959 |
+
border-radius: 2px;
|
| 960 |
+
overflow: hidden;
|
| 961 |
+
margin-top: 2px;
|
| 962 |
+
position: relative;
|
| 963 |
+
}
|
| 964 |
+
|
| 965 |
+
.channel-item.active .channel-epg-progress {
|
| 966 |
+
background: rgba(255, 255, 255, 0.3);
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
.channel-epg-progress-fill {
|
| 970 |
+
height: 100%;
|
| 971 |
+
background: var(--gradient-main);
|
| 972 |
+
width: 0%;
|
| 973 |
+
transition: width 1s linear;
|
| 974 |
+
border-radius: 2px;
|
| 975 |
+
box-shadow: 0 0 8px rgba(102, 126, 234, 0.5);
|
| 976 |
+
position: relative;
|
| 977 |
+
}
|
| 978 |
+
|
| 979 |
+
.channel-epg-progress-fill::after {
|
| 980 |
+
content: '';
|
| 981 |
+
position: absolute;
|
| 982 |
+
top: 0;
|
| 983 |
+
left: 0;
|
| 984 |
+
bottom: 0;
|
| 985 |
+
right: 0;
|
| 986 |
+
background: linear-gradient(90deg,
|
| 987 |
+
transparent,
|
| 988 |
+
rgba(255, 255, 255, 0.3),
|
| 989 |
+
transparent
|
| 990 |
+
);
|
| 991 |
+
animation: progressShine 2s infinite;
|
| 992 |
+
}
|
| 993 |
+
|
| 994 |
+
@keyframes progressShine {
|
| 995 |
+
0% { transform: translateX(-100%); }
|
| 996 |
+
100% { transform: translateX(100%); }
|
| 997 |
+
}
|
| 998 |
+
|
| 999 |
+
.channel-item.active .channel-epg-progress-fill {
|
| 1000 |
+
background: rgba(255, 255, 255, 0.9);
|
| 1001 |
+
box-shadow: 0 0 12px rgba(255, 255, 255, 0.6);
|
| 1002 |
+
}
|
| 1003 |
+
|
| 1004 |
+
.channel-list::-webkit-scrollbar {
|
| 1005 |
+
width: 10px;
|
| 1006 |
+
}
|
| 1007 |
+
|
| 1008 |
+
.channel-list::-webkit-scrollbar-track {
|
| 1009 |
+
background: rgba(102, 126, 234, 0.1);
|
| 1010 |
+
border-radius: 5px;
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
.channel-list::-webkit-scrollbar-thumb {
|
| 1014 |
+
background: var(--gradient-main);
|
| 1015 |
+
border-radius: 5px;
|
| 1016 |
+
transition: all 0.3s ease;
|
| 1017 |
+
}
|
| 1018 |
+
|
| 1019 |
+
.channel-list::-webkit-scrollbar-thumb:hover {
|
| 1020 |
+
background: linear-gradient(180deg, #764ba2 0%, #667eea 100%);
|
| 1021 |
+
}
|
| 1022 |
+
|
| 1023 |
+
@media (max-width: 1024px) {
|
| 1024 |
+
.mdl-player-layout {
|
| 1025 |
+
grid-template-columns: 1fr;
|
| 1026 |
+
}
|
| 1027 |
+
|
| 1028 |
+
.mdl-channel-sidebar {
|
| 1029 |
+
max-height: 400px;
|
| 1030 |
+
border-left: none;
|
| 1031 |
+
border-top: 1px solid var(--glass-border);
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
.mdl-video-wrapper {
|
| 1035 |
+
max-height: 50vh;
|
| 1036 |
+
border-radius: 16px;
|
| 1037 |
+
}
|
| 1038 |
+
|
| 1039 |
+
.sidebar-collapse-btn,
|
| 1040 |
+
.sidebar-expand-btn {
|
| 1041 |
+
display: none !important;
|
| 1042 |
+
}
|
| 1043 |
+
|
| 1044 |
+
.placeholder-icon {
|
| 1045 |
+
font-size: 60px;
|
| 1046 |
+
}
|
| 1047 |
+
|
| 1048 |
+
.placeholder-text {
|
| 1049 |
+
font-size: 18px;
|
| 1050 |
+
}
|
| 1051 |
+
}
|
| 1052 |
+
|
| 1053 |
+
@media (max-width: 768px) {
|
| 1054 |
+
.category-tabs {
|
| 1055 |
+
grid-template-columns: repeat(3, 1fr);
|
| 1056 |
+
}
|
| 1057 |
+
|
| 1058 |
+
.category-tab {
|
| 1059 |
+
padding: 12px 8px;
|
| 1060 |
+
font-size: 12px;
|
| 1061 |
+
}
|
| 1062 |
+
|
| 1063 |
+
.header-left h3 {
|
| 1064 |
+
font-size: 17px;
|
| 1065 |
+
}
|
| 1066 |
+
|
| 1067 |
+
.mdl-info-panel .panel-body,
|
| 1068 |
+
.mdl-record-panel {
|
| 1069 |
+
padding: 18px;
|
| 1070 |
+
}
|
| 1071 |
+
|
| 1072 |
+
.program-title {
|
| 1073 |
+
font-size: 13px;
|
| 1074 |
+
}
|
| 1075 |
+
|
| 1076 |
+
.channel-meta {
|
| 1077 |
+
font-size: 11px;
|
| 1078 |
+
}
|
| 1079 |
+
|
| 1080 |
+
.placeholder-icon {
|
| 1081 |
+
font-size: 50px;
|
| 1082 |
+
}
|
| 1083 |
+
|
| 1084 |
+
.placeholder-text {
|
| 1085 |
+
font-size: 16px;
|
| 1086 |
+
}
|
| 1087 |
+
}
|
| 1088 |
+
|
| 1089 |
+
@media (max-width: 480px) {
|
| 1090 |
+
.mdl-player-layout {
|
| 1091 |
+
min-height: auto;
|
| 1092 |
+
}
|
| 1093 |
+
|
| 1094 |
+
.mdl-video-wrapper {
|
| 1095 |
+
max-height: 40vh;
|
| 1096 |
+
border-radius: 12px;
|
| 1097 |
+
}
|
| 1098 |
+
|
| 1099 |
+
.mdl-channel-sidebar {
|
| 1100 |
+
max-height: 50vh;
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
.category-tabs {
|
| 1104 |
+
grid-template-columns: repeat(2, 1fr);
|
| 1105 |
+
}
|
| 1106 |
+
|
| 1107 |
+
.header-left {
|
| 1108 |
+
flex-direction: column;
|
| 1109 |
+
align-items: flex-start;
|
| 1110 |
+
gap: 10px;
|
| 1111 |
+
}
|
| 1112 |
+
|
| 1113 |
+
.channel-item-content {
|
| 1114 |
+
gap: 10px;
|
| 1115 |
+
}
|
| 1116 |
+
|
| 1117 |
+
.channel-logo {
|
| 1118 |
+
width: 40px;
|
| 1119 |
+
height: 40px;
|
| 1120 |
+
font-size: 20px;
|
| 1121 |
+
}
|
| 1122 |
+
|
| 1123 |
+
.program-title {
|
| 1124 |
+
font-size: 13px;
|
| 1125 |
+
}
|
| 1126 |
+
|
| 1127 |
+
.channel-meta {
|
| 1128 |
+
font-size: 11px;
|
| 1129 |
+
gap: 6px;
|
| 1130 |
+
}
|
| 1131 |
+
|
| 1132 |
+
.channel-name-badge {
|
| 1133 |
+
padding: 2px 8px;
|
| 1134 |
+
font-size: 10px;
|
| 1135 |
+
}
|
| 1136 |
+
|
| 1137 |
+
.placeholder-icon {
|
| 1138 |
+
font-size: 40px;
|
| 1139 |
+
}
|
| 1140 |
+
|
| 1141 |
+
.placeholder-text {
|
| 1142 |
+
font-size: 14px;
|
| 1143 |
+
}
|
| 1144 |
+
|
| 1145 |
+
.loading-spinner {
|
| 1146 |
+
width: 60px;
|
| 1147 |
+
height: 60px;
|
| 1148 |
+
border-width: 4px;
|
| 1149 |
+
}
|
| 1150 |
+
}
|
| 1151 |
+
|
| 1152 |
+
.hidden {
|
| 1153 |
+
display: none !important;
|
| 1154 |
+
}
|
| 1155 |
+
|
| 1156 |
+
.text-center {
|
| 1157 |
+
text-align: center;
|
| 1158 |
+
}
|
| 1159 |
+
|
| 1160 |
+
@media print {
|
| 1161 |
+
.mdl-player-main {
|
| 1162 |
+
background: white;
|
| 1163 |
+
}
|
| 1164 |
+
|
| 1165 |
+
.sidebar-collapse-btn,
|
| 1166 |
+
.sidebar-expand-btn,
|
| 1167 |
+
.record-btn-mini,
|
| 1168 |
+
.collapse-btn {
|
| 1169 |
+
display: none;
|
| 1170 |
+
}
|
| 1171 |
+
}
|
| 1172 |
+
|
| 1173 |
+
* {
|
| 1174 |
+
-webkit-font-smoothing: antialiased;
|
| 1175 |
+
-moz-osx-font-smoothing: grayscale;
|
| 1176 |
+
}
|
| 1177 |
+
|
| 1178 |
+
.channel-logo,
|
| 1179 |
+
.sidebar-collapse-btn,
|
| 1180 |
+
.collapse-btn,
|
| 1181 |
+
.record-btn-mini,
|
| 1182 |
+
.category-tab {
|
| 1183 |
+
user-select: none;
|
| 1184 |
+
-webkit-user-select: none;
|
| 1185 |
+
-moz-user-select: none;
|
| 1186 |
+
-ms-user-select: none;
|
| 1187 |
+
}
|
| 1188 |
+
|
| 1189 |
+
.loading-state p {
|
| 1190 |
+
margin-top: 10px;
|
| 1191 |
+
font-size: 14px;
|
| 1192 |
+
font-weight: 600;
|
| 1193 |
+
}
|
| 1194 |
+
|
| 1195 |
+
.channel-list:empty::after {
|
| 1196 |
+
content: '暂无频道';
|
| 1197 |
+
display: block;
|
| 1198 |
+
text-align: center;
|
| 1199 |
+
padding: 80px 20px;
|
| 1200 |
+
color: #94a3b8;
|
| 1201 |
+
font-size: 16px;
|
| 1202 |
+
}
|
| 1203 |
+
</style>
|
| 1204 |
+
|
| 1205 |
+
<script>
|
| 1206 |
+
(function() {
|
| 1207 |
+
'use strict';
|
| 1208 |
+
|
| 1209 |
+
const API = window.location.origin;
|
| 1210 |
+
let dp = null;
|
| 1211 |
+
let currentChannel = null;
|
| 1212 |
+
let currentChannelIndex = -1;
|
| 1213 |
+
let channels = [];
|
| 1214 |
+
let filteredChannels = [];
|
| 1215 |
+
let currentCategory = 'all';
|
| 1216 |
+
let cachedStreamUrls = new Map();
|
| 1217 |
+
let epgUpdateInterval = null;
|
| 1218 |
+
let currentEpgData = null;
|
| 1219 |
+
let currentDisplayedProgram = null;
|
| 1220 |
+
let sidebarEpgInterval = null;
|
| 1221 |
+
|
| 1222 |
+
let favoriteChannels = new Set();
|
| 1223 |
+
// ✅ 添加工具函数
|
| 1224 |
+
const Storage = {
|
| 1225 |
+
get: (key, defaultValue = null) => {
|
| 1226 |
+
try {
|
| 1227 |
+
const value = localStorage.getItem(key);
|
| 1228 |
+
return value ? JSON.parse(value) : defaultValue;
|
| 1229 |
+
} catch (e) {
|
| 1230 |
+
return defaultValue;
|
| 1231 |
+
}
|
| 1232 |
+
},
|
| 1233 |
+
set: (key, value) => {
|
| 1234 |
+
try {
|
| 1235 |
+
localStorage.setItem(key, JSON.stringify(value));
|
| 1236 |
+
} catch (e) {
|
| 1237 |
+
}
|
| 1238 |
+
},
|
| 1239 |
+
remove: (key) => {
|
| 1240 |
+
try {
|
| 1241 |
+
localStorage.removeItem(key);
|
| 1242 |
+
} catch (e) {
|
| 1243 |
+
}
|
| 1244 |
+
}
|
| 1245 |
+
};
|
| 1246 |
+
function getCurrentUsername() {
|
| 1247 |
+
return sessionStorage.getItem('username') || 'guest';
|
| 1248 |
+
}
|
| 1249 |
+
function getUserStorageKey(key) {
|
| 1250 |
+
const username = getCurrentUsername();
|
| 1251 |
+
return `${username}_${key}`;
|
| 1252 |
+
}
|
| 1253 |
+
|
| 1254 |
+
function loadFavorites() {
|
| 1255 |
+
if (window.userDataSync && window.userDataSync.isInitialized) {
|
| 1256 |
+
const favorites = window.userDataSync.getFavorites();
|
| 1257 |
+
favoriteChannels = new Set(favorites);
|
| 1258 |
+
} else {
|
| 1259 |
+
const key = getUserStorageKey('favoriteChannels');
|
| 1260 |
+
const saved = Storage.get(key, []);
|
| 1261 |
+
favoriteChannels = new Set(saved);
|
| 1262 |
+
}
|
| 1263 |
+
}
|
| 1264 |
+
function saveFavorites() {
|
| 1265 |
+
const favorites = Array.from(favoriteChannels);
|
| 1266 |
+
if (window.userDataSync && window.userDataSync.isInitialized) {
|
| 1267 |
+
window.userDataSync.setFavorites(favorites);
|
| 1268 |
+
} else {
|
| 1269 |
+
const key = getUserStorageKey('favoriteChannels');
|
| 1270 |
+
Storage.set(key, favorites);
|
| 1271 |
+
}
|
| 1272 |
+
}
|
| 1273 |
+
|
| 1274 |
+
window.toggleFavorite = function(channelNo) {
|
| 1275 |
+
if (favoriteChannels.has(channelNo)) {
|
| 1276 |
+
favoriteChannels.delete(channelNo);
|
| 1277 |
+
} else {
|
| 1278 |
+
favoriteChannels.add(channelNo);
|
| 1279 |
+
}
|
| 1280 |
+
saveFavorites();
|
| 1281 |
+
updateFavoriteButton();
|
| 1282 |
+
renderChannelList();
|
| 1283 |
+
};
|
| 1284 |
+
|
| 1285 |
+
function updateFavoriteButton() {
|
| 1286 |
+
if (!currentChannel) return;
|
| 1287 |
+
const allButtons = document.querySelectorAll(`.favorite-btn[data-channel-no="${currentChannel.no}"]`);
|
| 1288 |
+
allButtons.forEach(btn => {
|
| 1289 |
+
const isFav = favoriteChannels.has(currentChannel.no);
|
| 1290 |
+
btn.classList.toggle('active', isFav);
|
| 1291 |
+
btn.textContent = isFav ? '★' : '☆';
|
| 1292 |
+
btn.title = isFav ? '取消收藏' : '添加收藏';
|
| 1293 |
+
});
|
| 1294 |
+
}
|
| 1295 |
+
function getJSTNow() {
|
| 1296 |
+
const now = new Date();
|
| 1297 |
+
const jst = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Tokyo' }));
|
| 1298 |
+
return jst;
|
| 1299 |
+
}
|
| 1300 |
+
|
| 1301 |
+
function getJSTTimestamp() {
|
| 1302 |
+
return Math.floor(getJSTNow().getTime() / 1000);
|
| 1303 |
+
}
|
| 1304 |
+
|
| 1305 |
+
function formatJSTTime(timestamp) {
|
| 1306 |
+
const d = new Date(timestamp * 1000);
|
| 1307 |
+
const jstStr = d.toLocaleString('en-US', {
|
| 1308 |
+
timeZone: 'Asia/Tokyo',
|
| 1309 |
+
hour: '2-digit',
|
| 1310 |
+
minute: '2-digit',
|
| 1311 |
+
hour12: false
|
| 1312 |
+
});
|
| 1313 |
+
return jstStr;
|
| 1314 |
+
}
|
| 1315 |
+
|
| 1316 |
+
function getJSTDateString(date) {
|
| 1317 |
+
const d = date || new Date();
|
| 1318 |
+
const jst = new Date(d.toLocaleString('en-US', { timeZone: 'Asia/Tokyo' }));
|
| 1319 |
+
const year = jst.getFullYear();
|
| 1320 |
+
const month = String(jst.getMonth() + 1).padStart(2, '0');
|
| 1321 |
+
const day = String(jst.getDate()).padStart(2, '0');
|
| 1322 |
+
return `${year}-${month}-${day}`;
|
| 1323 |
+
}
|
| 1324 |
+
|
| 1325 |
+
function formatJSTClock() {
|
| 1326 |
+
const jst = getJSTNow();
|
| 1327 |
+
const hours = String(jst.getHours()).padStart(2, '0');
|
| 1328 |
+
const minutes = String(jst.getMinutes()).padStart(2, '0');
|
| 1329 |
+
return `${hours}:${minutes}`;
|
| 1330 |
+
}
|
| 1331 |
+
|
| 1332 |
+
class Recorder {
|
| 1333 |
+
constructor() {
|
| 1334 |
+
this.active = false;
|
| 1335 |
+
this.chunks = [];
|
| 1336 |
+
this.durations = [];
|
| 1337 |
+
this.urls = new Set();
|
| 1338 |
+
this.timer = null;
|
| 1339 |
+
this.abort = null;
|
| 1340 |
+
this.m3u8 = '';
|
| 1341 |
+
this.running = false;
|
| 1342 |
+
this.startTime = 0;
|
| 1343 |
+
}
|
| 1344 |
+
|
| 1345 |
+
async start(url, { onProgress, onError }) {
|
| 1346 |
+
if (this.active) return;
|
| 1347 |
+
|
| 1348 |
+
this.active = this.running = true;
|
| 1349 |
+
this.chunks = [];
|
| 1350 |
+
this.durations = [];
|
| 1351 |
+
this.urls = new Set();
|
| 1352 |
+
this.m3u8 = url;
|
| 1353 |
+
this.abort = new AbortController();
|
| 1354 |
+
this.startTime = Date.now();
|
| 1355 |
+
|
| 1356 |
+
if (onProgress) {
|
| 1357 |
+
this.timer = setInterval(() => {
|
| 1358 |
+
if (!this.active) return;
|
| 1359 |
+
const duration = this.durations.reduce((s, d) => s + d, 0);
|
| 1360 |
+
onProgress({
|
| 1361 |
+
segments: this.chunks.length,
|
| 1362 |
+
duration,
|
| 1363 |
+
size: this.chunks.reduce((s, c) => s + c.byteLength, 0)
|
| 1364 |
+
});
|
| 1365 |
+
}, 1000);
|
| 1366 |
+
}
|
| 1367 |
+
|
| 1368 |
+
this.loop(onError);
|
| 1369 |
+
}
|
| 1370 |
+
|
| 1371 |
+
async loop(onError) {
|
| 1372 |
+
let retries = 0;
|
| 1373 |
+
let isFirstFetch = true;
|
| 1374 |
+
|
| 1375 |
+
while (this.running && this.active) {
|
| 1376 |
+
try {
|
| 1377 |
+
const res = await fetch(this.m3u8, {
|
| 1378 |
+
signal: this.abort.signal
|
| 1379 |
+
});
|
| 1380 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 1381 |
+
|
| 1382 |
+
const text = await res.text();
|
| 1383 |
+
const segments = this.parse(text);
|
| 1384 |
+
|
| 1385 |
+
await this.download(segments, isFirstFetch);
|
| 1386 |
+
isFirstFetch = false;
|
| 1387 |
+
|
| 1388 |
+
await this.sleep(2000);
|
| 1389 |
+
retries = 0;
|
| 1390 |
+
|
| 1391 |
+
} catch (e) {
|
| 1392 |
+
if (e.name === 'AbortError') break;
|
| 1393 |
+
if (++retries >= 3) {
|
| 1394 |
+
if (onError) onError(e);
|
| 1395 |
+
break;
|
| 1396 |
+
}
|
| 1397 |
+
await this.sleep(1000);
|
| 1398 |
+
}
|
| 1399 |
+
}
|
| 1400 |
+
}
|
| 1401 |
+
|
| 1402 |
+
parse(content) {
|
| 1403 |
+
const lines = content.split('\n');
|
| 1404 |
+
const segments = [];
|
| 1405 |
+
let duration = 0;
|
| 1406 |
+
|
| 1407 |
+
for (const line of lines) {
|
| 1408 |
+
const l = line.trim();
|
| 1409 |
+
if (l.startsWith('#EXTINF:')) {
|
| 1410 |
+
const m = l.match(/#EXTINF:([\d.]+)/);
|
| 1411 |
+
if (m) duration = parseFloat(m[1]);
|
| 1412 |
+
}
|
| 1413 |
+
if (l && !l.startsWith('#')) {
|
| 1414 |
+
let url = l;
|
| 1415 |
+
if (!url.startsWith('http')) {
|
| 1416 |
+
url = this.m3u8.substring(0, this.m3u8.lastIndexOf('/') + 1) + url;
|
| 1417 |
+
}
|
| 1418 |
+
segments.push({ url, duration: duration || 0 });
|
| 1419 |
+
duration = 0;
|
| 1420 |
+
}
|
| 1421 |
+
}
|
| 1422 |
+
return segments;
|
| 1423 |
+
}
|
| 1424 |
+
|
| 1425 |
+
async download(segments, isFirstFetch) {
|
| 1426 |
+
if (isFirstFetch && segments.length > 0) {
|
| 1427 |
+
const lastSegment = segments[segments.length - 1];
|
| 1428 |
+
segments.length = 0;
|
| 1429 |
+
segments.push(lastSegment);
|
| 1430 |
+
}
|
| 1431 |
+
|
| 1432 |
+
for (const seg of segments) {
|
| 1433 |
+
if (this.urls.has(seg.url) || !this.active) continue;
|
| 1434 |
+
|
| 1435 |
+
try {
|
| 1436 |
+
const res = await fetch(seg.url, { signal: this.abort.signal });
|
| 1437 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 1438 |
+
|
| 1439 |
+
const buffer = await res.arrayBuffer();
|
| 1440 |
+
this.chunks.push(buffer);
|
| 1441 |
+
this.durations.push(seg.duration);
|
| 1442 |
+
this.urls.add(seg.url);
|
| 1443 |
+
} catch (e) {
|
| 1444 |
+
if (e.name === 'AbortError') break;
|
| 1445 |
+
}
|
| 1446 |
+
}
|
| 1447 |
+
}
|
| 1448 |
+
|
| 1449 |
+
async stop() {
|
| 1450 |
+
this.active = this.running = false;
|
| 1451 |
+
if (this.timer) clearInterval(this.timer);
|
| 1452 |
+
if (this.abort) this.abort.abort();
|
| 1453 |
+
|
| 1454 |
+
const size = this.chunks.reduce((s, c) => s + c.byteLength, 0);
|
| 1455 |
+
const duration = this.durations.reduce((s, d) => s + d, 0);
|
| 1456 |
+
const blob = new Blob(this.chunks, { type: 'video/mp2t' });
|
| 1457 |
+
|
| 1458 |
+
return {
|
| 1459 |
+
blob,
|
| 1460 |
+
segments: this.chunks.length,
|
| 1461 |
+
size,
|
| 1462 |
+
duration: duration.toFixed(2),
|
| 1463 |
+
url: URL.createObjectURL(blob)
|
| 1464 |
+
};
|
| 1465 |
+
}
|
| 1466 |
+
|
| 1467 |
+
sleep(ms) {
|
| 1468 |
+
return new Promise(r => setTimeout(r, ms));
|
| 1469 |
+
}
|
| 1470 |
+
}
|
| 1471 |
+
|
| 1472 |
+
const recorder = new Recorder();
|
| 1473 |
+
|
| 1474 |
+
const fmt = {
|
| 1475 |
+
bytes: b => b < 1024 ? b + 'B' : b < 1024*1024 ? (b/1024).toFixed(2) + 'KB' : (b/1024/1024).toFixed(2) + 'MB',
|
| 1476 |
+
time: s => `${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,'0')}`
|
| 1477 |
+
};
|
| 1478 |
+
|
| 1479 |
+
function getCategoryFromTags(tags) {
|
| 1480 |
+
if (!tags) return 'その他';
|
| 1481 |
+
|
| 1482 |
+
if (tags.includes('$LIVE_CAT_関東')) return '関東';
|
| 1483 |
+
if (tags.includes('$LIVE_CAT_関西')) return '関西';
|
| 1484 |
+
if (tags.includes('$LIVE_CAT_BS')) return 'BS';
|
| 1485 |
+
if (tags.includes('$LIVE_CAT_CS')) return 'CS';
|
| 1486 |
+
|
| 1487 |
+
return 'その他';
|
| 1488 |
+
}
|
| 1489 |
+
|
| 1490 |
+
function initPlayer() {
|
| 1491 |
+
if (typeof DPlayer === 'undefined') {
|
| 1492 |
+
setTimeout(initPlayer, 500);
|
| 1493 |
+
return false;
|
| 1494 |
+
}
|
| 1495 |
+
|
| 1496 |
+
if (typeof Hls === 'undefined') {
|
| 1497 |
+
setTimeout(initPlayer, 500);
|
| 1498 |
+
return false;
|
| 1499 |
+
}
|
| 1500 |
+
|
| 1501 |
+
initKeyboardShortcuts();
|
| 1502 |
+
|
| 1503 |
+
return true;
|
| 1504 |
+
}
|
| 1505 |
+
|
| 1506 |
+
function initKeyboardShortcuts() {
|
| 1507 |
+
document.addEventListener('keydown', (e) => {
|
| 1508 |
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
| 1509 |
+
return;
|
| 1510 |
+
}
|
| 1511 |
+
|
| 1512 |
+
if (!dp) return;
|
| 1513 |
+
|
| 1514 |
+
switch(e.key.toLowerCase()) {
|
| 1515 |
+
case ' ':
|
| 1516 |
+
e.preventDefault();
|
| 1517 |
+
dp.toggle();
|
| 1518 |
+
break;
|
| 1519 |
+
|
| 1520 |
+
case 'f':
|
| 1521 |
+
e.preventDefault();
|
| 1522 |
+
dp.fullScreen.toggle();
|
| 1523 |
+
break;
|
| 1524 |
+
|
| 1525 |
+
case 'm':
|
| 1526 |
+
e.preventDefault();
|
| 1527 |
+
if (dp.video) {
|
| 1528 |
+
dp.video.muted = !dp.video.muted;
|
| 1529 |
+
}
|
| 1530 |
+
break;
|
| 1531 |
+
|
| 1532 |
+
case 'arrowup':
|
| 1533 |
+
e.preventDefault();
|
| 1534 |
+
if (dp.video) {
|
| 1535 |
+
dp.video.volume = Math.min(1, dp.video.volume + 0.1);
|
| 1536 |
+
}
|
| 1537 |
+
break;
|
| 1538 |
+
|
| 1539 |
+
case 'arrowdown':
|
| 1540 |
+
e.preventDefault();
|
| 1541 |
+
if (dp.video) {
|
| 1542 |
+
dp.video.volume = Math.max(0, dp.video.volume - 0.1);
|
| 1543 |
+
}
|
| 1544 |
+
break;
|
| 1545 |
+
|
| 1546 |
+
case 'n':
|
| 1547 |
+
e.preventDefault();
|
| 1548 |
+
switchChannel('next');
|
| 1549 |
+
break;
|
| 1550 |
+
|
| 1551 |
+
case 'p':
|
| 1552 |
+
e.preventDefault();
|
| 1553 |
+
switchChannel('prev');
|
| 1554 |
+
break;
|
| 1555 |
+
|
| 1556 |
+
case 'c':
|
| 1557 |
+
e.preventDefault();
|
| 1558 |
+
takeCustomScreenshot();
|
| 1559 |
+
break;
|
| 1560 |
+
}
|
| 1561 |
+
});
|
| 1562 |
+
}
|
| 1563 |
+
|
| 1564 |
+
function switchChannel(direction) {
|
| 1565 |
+
if (filteredChannels.length === 0) {
|
| 1566 |
+
return;
|
| 1567 |
+
}
|
| 1568 |
+
|
| 1569 |
+
let newIndex = currentChannelIndex;
|
| 1570 |
+
|
| 1571 |
+
if (direction === 'next') {
|
| 1572 |
+
newIndex = (currentChannelIndex + 1) % filteredChannels.length;
|
| 1573 |
+
} else if (direction === 'prev') {
|
| 1574 |
+
newIndex = (currentChannelIndex - 1 + filteredChannels.length) % filteredChannels.length;
|
| 1575 |
+
}
|
| 1576 |
+
|
| 1577 |
+
const nextChannel = filteredChannels[newIndex];
|
| 1578 |
+
if (nextChannel) {
|
| 1579 |
+
playChannel(nextChannel.no);
|
| 1580 |
+
}
|
| 1581 |
+
}
|
| 1582 |
+
|
| 1583 |
+
window.toggleInfoPanel = function() {
|
| 1584 |
+
const panel = document.getElementById('infoPanel');
|
| 1585 |
+
if (panel) {
|
| 1586 |
+
panel.classList.toggle('collapsed');
|
| 1587 |
+
}
|
| 1588 |
+
};
|
| 1589 |
+
|
| 1590 |
+
function showLoadingIndicator() {
|
| 1591 |
+
const indicator = document.getElementById('loadingIndicator');
|
| 1592 |
+
if (indicator) {
|
| 1593 |
+
indicator.classList.remove('hidden');
|
| 1594 |
+
}
|
| 1595 |
+
}
|
| 1596 |
+
|
| 1597 |
+
function hideLoadingIndicator() {
|
| 1598 |
+
const indicator = document.getElementById('loadingIndicator');
|
| 1599 |
+
if (indicator) {
|
| 1600 |
+
indicator.classList.add('hidden');
|
| 1601 |
+
}
|
| 1602 |
+
}
|
| 1603 |
+
|
| 1604 |
+
function hidePlaceholder() {
|
| 1605 |
+
const placeholder = document.getElementById('playerPlaceholder');
|
| 1606 |
+
if (placeholder) {
|
| 1607 |
+
placeholder.classList.add('hidden');
|
| 1608 |
+
}
|
| 1609 |
+
}
|
| 1610 |
+
|
| 1611 |
+
function showLiveBadge() {
|
| 1612 |
+
const liveBadge = document.getElementById('liveBadge');
|
| 1613 |
+
if (liveBadge) {
|
| 1614 |
+
liveBadge.style.display = 'flex';
|
| 1615 |
+
liveBadge.classList.remove('hidden');
|
| 1616 |
+
}
|
| 1617 |
+
}
|
| 1618 |
+
|
| 1619 |
+
function hideLiveBadge() {
|
| 1620 |
+
const liveBadge = document.getElementById('liveBadge');
|
| 1621 |
+
if (liveBadge) {
|
| 1622 |
+
liveBadge.classList.add('hidden');
|
| 1623 |
+
setTimeout(() => {
|
| 1624 |
+
liveBadge.style.display = 'none';
|
| 1625 |
+
}, 300);
|
| 1626 |
+
}
|
| 1627 |
+
}
|
| 1628 |
+
|
| 1629 |
+
async function loadChannels() {
|
| 1630 |
+
try {
|
| 1631 |
+
const res = await fetch(`${API}/api/list`);
|
| 1632 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 1633 |
+
|
| 1634 |
+
const data = await res.json();
|
| 1635 |
+
if (!data.success) throw new Error(data.error || '加载失败');
|
| 1636 |
+
|
| 1637 |
+
channels = data.channels || [];
|
| 1638 |
+
|
| 1639 |
+
channels.forEach(ch => {
|
| 1640 |
+
ch.category = getCategoryFromTags(ch.tags);
|
| 1641 |
+
});
|
| 1642 |
+
|
| 1643 |
+
filterChannels(currentCategory);
|
| 1644 |
+
renderChannelList();
|
| 1645 |
+
loadAllChannelsEPG();
|
| 1646 |
+
|
| 1647 |
+
} catch (e) {
|
| 1648 |
+
const listEl = document.getElementById('channelList');
|
| 1649 |
+
if (listEl) {
|
| 1650 |
+
listEl.innerHTML = '<div class="loading-state"><p style="color: #ef4444;">❌ 加载失败</p></div>';
|
| 1651 |
+
}
|
| 1652 |
+
}
|
| 1653 |
+
}
|
| 1654 |
+
|
| 1655 |
+
async function loadAllChannelsEPG() {
|
| 1656 |
+
const dateStr = getJSTDateString();
|
| 1657 |
+
let loadedCount = 0;
|
| 1658 |
+
|
| 1659 |
+
const promises = channels.map(async (ch) => {
|
| 1660 |
+
try {
|
| 1661 |
+
const res = await fetch(`${API}/api/epg?vid=${ch.id}&date=${dateStr}`);
|
| 1662 |
+
const data = await res.json();
|
| 1663 |
+
|
| 1664 |
+
if (data.success && data.epg && data.epg.length > 0) {
|
| 1665 |
+
ch.epgData = data.epg;
|
| 1666 |
+
loadedCount++;
|
| 1667 |
+
|
| 1668 |
+
updateChannelEPGDisplay(ch.no);
|
| 1669 |
+
|
| 1670 |
+
return { success: true, channel: ch.name };
|
| 1671 |
+
}
|
| 1672 |
+
return { success: false, channel: ch.name };
|
| 1673 |
+
} catch (e) {
|
| 1674 |
+
return { success: false, channel: ch.name, error: e.message };
|
| 1675 |
+
}
|
| 1676 |
+
});
|
| 1677 |
+
|
| 1678 |
+
await Promise.allSettled(promises);
|
| 1679 |
+
|
| 1680 |
+
startSidebarEpgUpdate();
|
| 1681 |
+
}
|
| 1682 |
+
|
| 1683 |
+
function updateChannelEPGDisplay(channelNo) {
|
| 1684 |
+
const nowSec = getJSTTimestamp();
|
| 1685 |
+
const channel = channels.find(c => String(c.no) === String(channelNo));
|
| 1686 |
+
|
| 1687 |
+
if (!channel || !channel.epgData) return;
|
| 1688 |
+
|
| 1689 |
+
const currentProgram = channel.epgData.find(p =>
|
| 1690 |
+
p.time <= nowSec && p.time_end > nowSec
|
| 1691 |
+
);
|
| 1692 |
+
|
| 1693 |
+
const channelItem = document.querySelector(`.channel-item[data-channel-no="${channelNo}"]`);
|
| 1694 |
+
if (!channelItem) return;
|
| 1695 |
+
|
| 1696 |
+
const programTitleEl = channelItem.querySelector('.program-title');
|
| 1697 |
+
const programTimeEl = channelItem.querySelector('.program-time');
|
| 1698 |
+
const progressEl = channelItem.querySelector('.channel-epg-progress-fill');
|
| 1699 |
+
|
| 1700 |
+
if (currentProgram) {
|
| 1701 |
+
if (programTitleEl) {
|
| 1702 |
+
programTitleEl.textContent = currentProgram.title || currentProgram.name || '正在播放';
|
| 1703 |
+
}
|
| 1704 |
+
|
| 1705 |
+
if (programTimeEl) {
|
| 1706 |
+
const startTime = formatJSTTime(currentProgram.time);
|
| 1707 |
+
const endTime = currentProgram.time_end ? formatJSTTime(currentProgram.time_end) : '--:--';
|
| 1708 |
+
programTimeEl.textContent = `⏰ ${startTime} ~ ${endTime}`;
|
| 1709 |
+
}
|
| 1710 |
+
|
| 1711 |
+
if (progressEl && currentProgram.time && currentProgram.time_end) {
|
| 1712 |
+
const duration = currentProgram.time_end - currentProgram.time;
|
| 1713 |
+
const elapsed = nowSec - currentProgram.time;
|
| 1714 |
+
const progress = Math.min(Math.max((elapsed / duration) * 100, 0), 100);
|
| 1715 |
+
progressEl.style.width = `${progress.toFixed(2)}%`;
|
| 1716 |
+
}
|
| 1717 |
+
} else {
|
| 1718 |
+
if (programTitleEl) {
|
| 1719 |
+
programTitleEl.textContent = '暂无节目信息';
|
| 1720 |
+
}
|
| 1721 |
+
if (programTimeEl) {
|
| 1722 |
+
programTimeEl.textContent = '⏰ --:-- ~ --:--';
|
| 1723 |
+
}
|
| 1724 |
+
if (progressEl) {
|
| 1725 |
+
progressEl.style.width = '0%';
|
| 1726 |
+
}
|
| 1727 |
+
}
|
| 1728 |
+
}
|
| 1729 |
+
|
| 1730 |
+
function startSidebarEpgUpdate() {
|
| 1731 |
+
if (sidebarEpgInterval) {
|
| 1732 |
+
clearInterval(sidebarEpgInterval);
|
| 1733 |
+
}
|
| 1734 |
+
|
| 1735 |
+
updateAllChannelEPG();
|
| 1736 |
+
|
| 1737 |
+
sidebarEpgInterval = setInterval(() => {
|
| 1738 |
+
updateAllChannelEPG();
|
| 1739 |
+
}, 2000);
|
| 1740 |
+
}
|
| 1741 |
+
|
| 1742 |
+
function updateAllChannelEPG() {
|
| 1743 |
+
const nowSec = getJSTTimestamp();
|
| 1744 |
+
|
| 1745 |
+
requestAnimationFrame(() => {
|
| 1746 |
+
channels.forEach(ch => {
|
| 1747 |
+
if (ch.epgData && ch.epgData.length > 0) {
|
| 1748 |
+
updateChannelEPGDisplay(ch.no);
|
| 1749 |
+
}
|
| 1750 |
+
});
|
| 1751 |
+
});
|
| 1752 |
+
}
|
| 1753 |
+
|
| 1754 |
+
function filterChannels(category) {
|
| 1755 |
+
currentCategory = category;
|
| 1756 |
+
|
| 1757 |
+
if (category === 'all') {
|
| 1758 |
+
filteredChannels = [...channels];
|
| 1759 |
+
} else if (category === 'favorites') {
|
| 1760 |
+
filteredChannels = channels.filter(ch => favoriteChannels.has(ch.no));
|
| 1761 |
+
} else {
|
| 1762 |
+
filteredChannels = channels.filter(ch => ch.category === category);
|
| 1763 |
+
}
|
| 1764 |
+
|
| 1765 |
+
const searchInput = document.getElementById('channelSearch');
|
| 1766 |
+
if (searchInput && searchInput.value.trim()) {
|
| 1767 |
+
const query = searchInput.value.toLowerCase().trim();
|
| 1768 |
+
filteredChannels = filteredChannels.filter(ch =>
|
| 1769 |
+
ch.name.toLowerCase().includes(query) ||
|
| 1770 |
+
String(ch.no).includes(query)
|
| 1771 |
+
);
|
| 1772 |
+
}
|
| 1773 |
+
|
| 1774 |
+
renderChannelList();
|
| 1775 |
+
}
|
| 1776 |
+
|
| 1777 |
+
function renderChannelList() {
|
| 1778 |
+
const listEl = document.getElementById('channelList');
|
| 1779 |
+
if (!listEl) return;
|
| 1780 |
+
|
| 1781 |
+
if (filteredChannels.length === 0) {
|
| 1782 |
+
listEl.innerHTML = '<div class="loading-state"><p>没有频道</p></div>';
|
| 1783 |
+
return;
|
| 1784 |
+
}
|
| 1785 |
+
|
| 1786 |
+
const fragment = document.createDocumentFragment();
|
| 1787 |
+
const nowSec = getJSTTimestamp();
|
| 1788 |
+
|
| 1789 |
+
filteredChannels.forEach((ch, index) => {
|
| 1790 |
+
const div = document.createElement('div');
|
| 1791 |
+
div.className = 'channel-item';
|
| 1792 |
+
if (currentChannel && currentChannel.no === ch.no) {
|
| 1793 |
+
div.classList.add('active');
|
| 1794 |
+
}
|
| 1795 |
+
div.dataset.channelNo = ch.no;
|
| 1796 |
+
div.dataset.index = index;
|
| 1797 |
+
|
| 1798 |
+
let currentProgram = null;
|
| 1799 |
+
let programText = '暂无节目信息';
|
| 1800 |
+
let timeText = '--:-- ~ --:--';
|
| 1801 |
+
let progress = 0;
|
| 1802 |
+
|
| 1803 |
+
if (ch.epgData && ch.epgData.length > 0) {
|
| 1804 |
+
currentProgram = ch.epgData.find(p =>
|
| 1805 |
+
p.time <= nowSec && p.time_end > nowSec
|
| 1806 |
+
);
|
| 1807 |
+
|
| 1808 |
+
if (currentProgram) {
|
| 1809 |
+
programText = currentProgram.title || currentProgram.name || '正在播放';
|
| 1810 |
+
|
| 1811 |
+
const startTime = formatJSTTime(currentProgram.time);
|
| 1812 |
+
const endTime = currentProgram.time_end ? formatJSTTime(currentProgram.time_end) : '--:--';
|
| 1813 |
+
timeText = `${startTime} ~ ${endTime}`;
|
| 1814 |
+
|
| 1815 |
+
const duration = currentProgram.time_end - currentProgram.time;
|
| 1816 |
+
const elapsed = nowSec - currentProgram.time;
|
| 1817 |
+
progress = Math.min(Math.max((elapsed / duration) * 100, 0), 100);
|
| 1818 |
+
}
|
| 1819 |
+
}
|
| 1820 |
+
|
| 1821 |
+
const isFavorite = favoriteChannels.has(ch.no);
|
| 1822 |
+
|
| 1823 |
+
div.innerHTML = `
|
| 1824 |
+
<div class="channel-item-content">
|
| 1825 |
+
<div class="channel-logo">📺</div>
|
| 1826 |
+
<div class="channel-details">
|
| 1827 |
+
<div class="program-title">${programText}</div>
|
| 1828 |
+
<div class="channel-meta">
|
| 1829 |
+
<span class="program-time">⏰ ${timeText}</span>
|
| 1830 |
+
<span class="channel-name-badge">${ch.name}</span>
|
| 1831 |
+
</div>
|
| 1832 |
+
<div class="channel-epg-progress">
|
| 1833 |
+
<div class="channel-epg-progress-fill" style="width: ${progress.toFixed(2)}%"></div>
|
| 1834 |
+
</div>
|
| 1835 |
+
</div>
|
| 1836 |
+
<button class="favorite-btn ${isFavorite ? 'active' : ''}"
|
| 1837 |
+
data-channel-no="${ch.no}"
|
| 1838 |
+
title="${isFavorite ? '取消收藏' : '添加收藏'}">
|
| 1839 |
+
${isFavorite ? '★' : '☆'}
|
| 1840 |
+
</button>
|
| 1841 |
+
</div>
|
| 1842 |
+
`;
|
| 1843 |
+
|
| 1844 |
+
const favoriteBtn = div.querySelector('.favorite-btn');
|
| 1845 |
+
if (favoriteBtn) {
|
| 1846 |
+
favoriteBtn.addEventListener('click', (e) => {
|
| 1847 |
+
e.stopPropagation();
|
| 1848 |
+
toggleFavorite(ch.no);
|
| 1849 |
+
});
|
| 1850 |
+
}
|
| 1851 |
+
|
| 1852 |
+
fragment.appendChild(div);
|
| 1853 |
+
});
|
| 1854 |
+
|
| 1855 |
+
listEl.innerHTML = '';
|
| 1856 |
+
listEl.appendChild(fragment);
|
| 1857 |
+
|
| 1858 |
+
if (channels.length > 0 && !sidebarEpgInterval) {
|
| 1859 |
+
startSidebarEpgUpdate();
|
| 1860 |
+
}
|
| 1861 |
+
}
|
| 1862 |
+
|
| 1863 |
+
async function playChannel(channelNo) {
|
| 1864 |
+
const ch = channels.find(c => String(c.no) === String(channelNo));
|
| 1865 |
+
if (!ch) return;
|
| 1866 |
+
|
| 1867 |
+
hidePlaceholder();
|
| 1868 |
+
showLoadingIndicator();
|
| 1869 |
+
showLiveBadge();
|
| 1870 |
+
|
| 1871 |
+
try {
|
| 1872 |
+
let url;
|
| 1873 |
+
|
| 1874 |
+
if (cachedStreamUrls.has(channelNo)) {
|
| 1875 |
+
url = cachedStreamUrls.get(channelNo);
|
| 1876 |
+
} else {
|
| 1877 |
+
const res = await fetch(`${API}/api/live/${channelNo}`);
|
| 1878 |
+
const data = await res.json();
|
| 1879 |
+
|
| 1880 |
+
if (!data.success) {
|
| 1881 |
+
throw new Error(data.error || '获取失败');
|
| 1882 |
+
}
|
| 1883 |
+
|
| 1884 |
+
url = data.stream.m3u8;
|
| 1885 |
+
cachedStreamUrls.set(channelNo, url);
|
| 1886 |
+
setTimeout(() => cachedStreamUrls.delete(channelNo), 5 * 60 * 1000);
|
| 1887 |
+
}
|
| 1888 |
+
|
| 1889 |
+
currentChannel = ch;
|
| 1890 |
+
currentChannelIndex = filteredChannels.findIndex(c => c.no === ch.no);
|
| 1891 |
+
|
| 1892 |
+
if (dp) {
|
| 1893 |
+
try {
|
| 1894 |
+
if (dp.plugins && dp.plugins.durationInterval) {
|
| 1895 |
+
clearInterval(dp.plugins.durationInterval);
|
| 1896 |
+
}
|
| 1897 |
+
dp.destroy();
|
| 1898 |
+
} catch (e) {
|
| 1899 |
+
}
|
| 1900 |
+
dp = null;
|
| 1901 |
+
}
|
| 1902 |
+
|
| 1903 |
+
const container = document.getElementById('dplayer');
|
| 1904 |
+
if (container) {
|
| 1905 |
+
container.innerHTML = '';
|
| 1906 |
+
}
|
| 1907 |
+
|
| 1908 |
+
let hasStartedPlaying = false;
|
| 1909 |
+
|
| 1910 |
+
dp = new DPlayer({
|
| 1911 |
+
container: document.getElementById('dplayer'),
|
| 1912 |
+
live: false,
|
| 1913 |
+
autoplay: true,
|
| 1914 |
+
theme: '#667eea',
|
| 1915 |
+
loop: false,
|
| 1916 |
+
lang: 'zh-cn',
|
| 1917 |
+
screenshot: false,
|
| 1918 |
+
hotkey: true,
|
| 1919 |
+
preload: 'auto',
|
| 1920 |
+
volume: 0.7,
|
| 1921 |
+
mutex: true,
|
| 1922 |
+
video: {
|
| 1923 |
+
url: url,
|
| 1924 |
+
type: 'customHls',
|
| 1925 |
+
customType: {
|
| 1926 |
+
customHls: (video, player) => {
|
| 1927 |
+
if (Hls.isSupported()) {
|
| 1928 |
+
const hls = new Hls({
|
| 1929 |
+
enableWorker: true,
|
| 1930 |
+
lowLatencyMode: false,
|
| 1931 |
+
backBufferLength: 0,
|
| 1932 |
+
maxBufferLength: 30,
|
| 1933 |
+
maxMaxBufferLength: 60,
|
| 1934 |
+
maxBufferSize: 60 * 1024 * 1024,
|
| 1935 |
+
maxBufferHole: 0.5,
|
| 1936 |
+
highBufferWatchdogPeriod: 2,
|
| 1937 |
+
nudgeMaxRetry: 10,
|
| 1938 |
+
liveBackBufferLength: Infinity,
|
| 1939 |
+
liveSyncDurationCount: 3,
|
| 1940 |
+
liveMaxLatencyDurationCount: Infinity,
|
| 1941 |
+
liveDurationInfinity: true,
|
| 1942 |
+
manifestLoadingTimeOut: 10000,
|
| 1943 |
+
manifestLoadingMaxRetry: 4,
|
| 1944 |
+
levelLoadingTimeOut: 10000,
|
| 1945 |
+
levelLoadingMaxRetry: 4,
|
| 1946 |
+
fragLoadingTimeOut: 20000,
|
| 1947 |
+
fragLoadingMaxRetry: 6,
|
| 1948 |
+
startLevel: -1,
|
| 1949 |
+
autoStartLoad: true,
|
| 1950 |
+
startFragPrefetch: true,
|
| 1951 |
+
testBandwidth: true,
|
| 1952 |
+
progressive: true,
|
| 1953 |
+
debug: false
|
| 1954 |
+
});
|
| 1955 |
+
|
| 1956 |
+
hls.loadSource(video.src);
|
| 1957 |
+
hls.attachMedia(video);
|
| 1958 |
+
|
| 1959 |
+
let startTime = Date.now();
|
| 1960 |
+
let durationUpdateInterval = null;
|
| 1961 |
+
|
| 1962 |
+
hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
|
| 1963 |
+
startTime = Date.now();
|
| 1964 |
+
video.play().catch(err => {});
|
| 1965 |
+
});
|
| 1966 |
+
|
| 1967 |
+
video.addEventListener('loadedmetadata', () => {
|
| 1968 |
+
Object.defineProperty(video, 'duration', {
|
| 1969 |
+
get: function() {
|
| 1970 |
+
return Infinity;
|
| 1971 |
+
},
|
| 1972 |
+
configurable: true
|
| 1973 |
+
});
|
| 1974 |
+
});
|
| 1975 |
+
|
| 1976 |
+
if (!durationUpdateInterval) {
|
| 1977 |
+
durationUpdateInterval = setInterval(() => {
|
| 1978 |
+
try {
|
| 1979 |
+
Object.defineProperty(video, 'duration', {
|
| 1980 |
+
get: function() {
|
| 1981 |
+
return Infinity;
|
| 1982 |
+
},
|
| 1983 |
+
configurable: true
|
| 1984 |
+
});
|
| 1985 |
+
} catch (e) {}
|
| 1986 |
+
}, 1000);
|
| 1987 |
+
}
|
| 1988 |
+
|
| 1989 |
+
hls.on(Hls.Events.FRAG_LOADED, (event, data) => {
|
| 1990 |
+
if (!hasStartedPlaying && video.readyState >= 3) {
|
| 1991 |
+
hasStartedPlaying = true;
|
| 1992 |
+
hideLoadingIndicator();
|
| 1993 |
+
hideLiveBadge();
|
| 1994 |
+
}
|
| 1995 |
+
});
|
| 1996 |
+
|
| 1997 |
+
hls.on(Hls.Events.ERROR, (event, data) => {
|
| 1998 |
+
if (data.fatal) {
|
| 1999 |
+
if (durationUpdateInterval) {
|
| 2000 |
+
clearInterval(durationUpdateInterval);
|
| 2001 |
+
durationUpdateInterval = null;
|
| 2002 |
+
}
|
| 2003 |
+
|
| 2004 |
+
hideLoadingIndicator();
|
| 2005 |
+
hideLiveBadge();
|
| 2006 |
+
|
| 2007 |
+
switch (data.type) {
|
| 2008 |
+
case Hls.ErrorTypes.NETWORK_ERROR:
|
| 2009 |
+
showLoadingIndicator();
|
| 2010 |
+
showLiveBadge();
|
| 2011 |
+
hls.startLoad();
|
| 2012 |
+
break;
|
| 2013 |
+
case Hls.ErrorTypes.MEDIA_ERROR:
|
| 2014 |
+
showLoadingIndicator();
|
| 2015 |
+
showLiveBadge();
|
| 2016 |
+
hls.recoverMediaError();
|
| 2017 |
+
break;
|
| 2018 |
+
default:
|
| 2019 |
+
hls.destroy();
|
| 2020 |
+
break;
|
| 2021 |
+
}
|
| 2022 |
+
}
|
| 2023 |
+
});
|
| 2024 |
+
|
| 2025 |
+
player.plugins.hls = hls;
|
| 2026 |
+
player.plugins.durationInterval = durationUpdateInterval;
|
| 2027 |
+
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
| 2028 |
+
video.src = video.src;
|
| 2029 |
+
}
|
| 2030 |
+
}
|
| 2031 |
+
}
|
| 2032 |
+
}
|
| 2033 |
+
});
|
| 2034 |
+
|
| 2035 |
+
setTimeout(() => {
|
| 2036 |
+
addCustomScreenshotButton();
|
| 2037 |
+
}, 1000);
|
| 2038 |
+
|
| 2039 |
+
dp.video.addEventListener('playing', () => {
|
| 2040 |
+
hasStartedPlaying = true;
|
| 2041 |
+
hideLoadingIndicator();
|
| 2042 |
+
hideLiveBadge();
|
| 2043 |
+
});
|
| 2044 |
+
|
| 2045 |
+
dp.video.addEventListener('waiting', () => {
|
| 2046 |
+
if (hasStartedPlaying) {
|
| 2047 |
+
showLoadingIndicator();
|
| 2048 |
+
showLiveBadge();
|
| 2049 |
+
}
|
| 2050 |
+
});
|
| 2051 |
+
|
| 2052 |
+
dp.video.addEventListener('canplay', () => {
|
| 2053 |
+
if (hasStartedPlaying && dp.video.readyState >= 3) {
|
| 2054 |
+
hideLoadingIndicator();
|
| 2055 |
+
hideLiveBadge();
|
| 2056 |
+
}
|
| 2057 |
+
});
|
| 2058 |
+
|
| 2059 |
+
dp.on('play', () => {
|
| 2060 |
+
const btn = document.getElementById('recordBtn');
|
| 2061 |
+
if (btn) btn.disabled = false;
|
| 2062 |
+
});
|
| 2063 |
+
|
| 2064 |
+
dp.on('pause', () => {
|
| 2065 |
+
});
|
| 2066 |
+
|
| 2067 |
+
dp.on('error', () => {
|
| 2068 |
+
hideLoadingIndicator();
|
| 2069 |
+
hideLiveBadge();
|
| 2070 |
+
});
|
| 2071 |
+
|
| 2072 |
+
updateActiveChannel();
|
| 2073 |
+
loadCurrentChannelEPG();
|
| 2074 |
+
|
| 2075 |
+
} catch (e) {
|
| 2076 |
+
hideLoadingIndicator();
|
| 2077 |
+
hideLiveBadge();
|
| 2078 |
+
}
|
| 2079 |
+
}
|
| 2080 |
+
|
| 2081 |
+
function addCustomScreenshotButton() {
|
| 2082 |
+
if (!dp || !dp.container) return;
|
| 2083 |
+
|
| 2084 |
+
const existingBtn = dp.container.querySelector('.custom-screenshot-btn');
|
| 2085 |
+
if (existingBtn) return;
|
| 2086 |
+
|
| 2087 |
+
const controllerRight = dp.container.querySelector('.dplayer-icons.dplayer-icons-right');
|
| 2088 |
+
if (!controllerRight) return;
|
| 2089 |
+
|
| 2090 |
+
const screenshotBtn = document.createElement('div');
|
| 2091 |
+
screenshotBtn.className = 'dplayer-icon dplayer-camera-icon custom-screenshot-btn';
|
| 2092 |
+
screenshotBtn.innerHTML = `
|
| 2093 |
+
<span class="dplayer-icon-content">
|
| 2094 |
+
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 24 24">
|
| 2095 |
+
<path d="M20,5h-2.586l-1.707-1.707C15.52,3.105,15.266,3,15,3H7C6.734,3,6.48,3.105,6.293,3.293L4.586,5H2C0.895,5,0,5.895,0,7v11c0,1.105,0.895,2,2,2h18c1.105,0,2-0.895,2-2V7C22,5.895,21.105,5,20,5z M11,17c-2.761,0-5-2.239-5-5s2.239-5,5-5s5,2.239,5,5S13.761,17,11,17z M11,9c-1.657,0-3,1.343-3,3s1.343,3,3,3s3-1.343,3-3S12.657,9,11,9z"></path>
|
| 2096 |
+
</svg>
|
| 2097 |
+
</span>
|
| 2098 |
+
`;
|
| 2099 |
+
screenshotBtn.style.cursor = 'pointer';
|
| 2100 |
+
|
| 2101 |
+
screenshotBtn.addEventListener('click', () => {
|
| 2102 |
+
takeCustomScreenshot();
|
| 2103 |
+
});
|
| 2104 |
+
|
| 2105 |
+
const firstIcon = controllerRight.firstChild;
|
| 2106 |
+
if (firstIcon) {
|
| 2107 |
+
controllerRight.insertBefore(screenshotBtn, firstIcon);
|
| 2108 |
+
} else {
|
| 2109 |
+
controllerRight.appendChild(screenshotBtn);
|
| 2110 |
+
}
|
| 2111 |
+
}
|
| 2112 |
+
|
| 2113 |
+
function takeCustomScreenshot() {
|
| 2114 |
+
if (!dp || !dp.video) return;
|
| 2115 |
+
|
| 2116 |
+
const video = dp.video;
|
| 2117 |
+
const canvas = document.createElement('canvas');
|
| 2118 |
+
canvas.width = video.videoWidth;
|
| 2119 |
+
canvas.height = video.videoHeight;
|
| 2120 |
+
|
| 2121 |
+
const ctx = canvas.getContext('2d');
|
| 2122 |
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
| 2123 |
+
|
| 2124 |
+
canvas.toBlob((blob) => {
|
| 2125 |
+
const jst = getJSTNow();
|
| 2126 |
+
const timestamp = `${jst.getFullYear()}${String(jst.getMonth()+1).padStart(2,'0')}${String(jst.getDate()).padStart(2,'0')}_${String(jst.getHours()).padStart(2,'0')}${String(jst.getMinutes()).padStart(2,'0')}${String(jst.getSeconds()).padStart(2,'0')}`;
|
| 2127 |
+
|
| 2128 |
+
let programName = '未知节目';
|
| 2129 |
+
if (currentEpgData) {
|
| 2130 |
+
const nowSec = getJSTTimestamp();
|
| 2131 |
+
const currentProgram = currentEpgData.find(p => p.time <= nowSec && p.time_end > nowSec);
|
| 2132 |
+
if (currentProgram && currentProgram.title) {
|
| 2133 |
+
programName = currentProgram.title;
|
| 2134 |
+
}
|
| 2135 |
+
}
|
| 2136 |
+
|
| 2137 |
+
const safeName = programName.replace(/[\\/:*?"<>|]/g, '_');
|
| 2138 |
+
const channelName = currentChannel ? currentChannel.name : 'unknown';
|
| 2139 |
+
const safeChannelName = channelName.replace(/[\\/:*?"<>|]/g, '_');
|
| 2140 |
+
|
| 2141 |
+
const filename = `${safeChannelName}_${safeName}_${timestamp}.png`;
|
| 2142 |
+
|
| 2143 |
+
const url = URL.createObjectURL(blob);
|
| 2144 |
+
const link = document.createElement('a');
|
| 2145 |
+
link.href = url;
|
| 2146 |
+
link.download = filename;
|
| 2147 |
+
link.style.display = 'none';
|
| 2148 |
+
document.body.appendChild(link);
|
| 2149 |
+
link.click();
|
| 2150 |
+
document.body.removeChild(link);
|
| 2151 |
+
|
| 2152 |
+
setTimeout(() => URL.revokeObjectURL(url), 100);
|
| 2153 |
+
}, 'image/png');
|
| 2154 |
+
}
|
| 2155 |
+
|
| 2156 |
+
async function loadCurrentChannelEPG() {
|
| 2157 |
+
if (!currentChannel) return;
|
| 2158 |
+
|
| 2159 |
+
const dateStr = getJSTDateString();
|
| 2160 |
+
|
| 2161 |
+
try {
|
| 2162 |
+
const res = await fetch(`${API}/api/epg?vid=${currentChannel.id}&date=${dateStr}`);
|
| 2163 |
+
const data = await res.json();
|
| 2164 |
+
|
| 2165 |
+
if (data.success && data.epg && data.epg.length > 0) {
|
| 2166 |
+
currentChannel.epgData = data.epg;
|
| 2167 |
+
currentEpgData = data.epg;
|
| 2168 |
+
currentDisplayedProgram = null;
|
| 2169 |
+
updateEPGDisplay();
|
| 2170 |
+
startEPGUpdateInterval();
|
| 2171 |
+
} else {
|
| 2172 |
+
updateEPGDisplay({
|
| 2173 |
+
title: currentChannel.name,
|
| 2174 |
+
description: '暂无节目信息'
|
| 2175 |
+
});
|
| 2176 |
+
}
|
| 2177 |
+
} catch (e) {
|
| 2178 |
+
updateEPGDisplay({
|
| 2179 |
+
title: currentChannel.name,
|
| 2180 |
+
description: '节目信息加载失败'
|
| 2181 |
+
});
|
| 2182 |
+
}
|
| 2183 |
+
}
|
| 2184 |
+
|
| 2185 |
+
function updateEPGDisplay(program) {
|
| 2186 |
+
const titleEl = document.getElementById('currentProgramTitle');
|
| 2187 |
+
const timeEl = document.getElementById('programTime');
|
| 2188 |
+
const fillEl = document.getElementById('programProgress');
|
| 2189 |
+
|
| 2190 |
+
if (!program && currentEpgData) {
|
| 2191 |
+
const nowSec = getJSTTimestamp();
|
| 2192 |
+
program = currentEpgData.find(p => p.time <= nowSec && p.time_end > nowSec);
|
| 2193 |
+
}
|
| 2194 |
+
|
| 2195 |
+
if (!program) {
|
| 2196 |
+
if (titleEl) titleEl.textContent = currentChannel ? currentChannel.name : '选择频道以查看节目信息';
|
| 2197 |
+
if (timeEl) timeEl.textContent = '--:-- ~ --:--';
|
| 2198 |
+
if (fillEl) {
|
| 2199 |
+
fillEl.style.transition = 'none';
|
| 2200 |
+
fillEl.style.width = '0%';
|
| 2201 |
+
}
|
| 2202 |
+
currentDisplayedProgram = null;
|
| 2203 |
+
return;
|
| 2204 |
+
}
|
| 2205 |
+
|
| 2206 |
+
const programChanged = !currentDisplayedProgram ||
|
| 2207 |
+
currentDisplayedProgram.time !== program.time ||
|
| 2208 |
+
currentDisplayedProgram.time_end !== program.time_end;
|
| 2209 |
+
|
| 2210 |
+
if (programChanged) {
|
| 2211 |
+
currentDisplayedProgram = program;
|
| 2212 |
+
|
| 2213 |
+
if (titleEl) titleEl.textContent = program.title || program.name || '未知节目';
|
| 2214 |
+
if (timeEl) {
|
| 2215 |
+
const start = formatJSTTime(program.time);
|
| 2216 |
+
const end = program.time_end ? formatJSTTime(program.time_end) : '--:--';
|
| 2217 |
+
timeEl.textContent = `${start} ~ ${end}`;
|
| 2218 |
+
}
|
| 2219 |
+
|
| 2220 |
+
if (fillEl && program.time && program.time_end) {
|
| 2221 |
+
const nowSec = getJSTTimestamp();
|
| 2222 |
+
const duration = program.time_end - program.time;
|
| 2223 |
+
const elapsed = Math.max(0, nowSec - program.time);
|
| 2224 |
+
const progress = Math.min((elapsed / duration) * 100, 100);
|
| 2225 |
+
|
| 2226 |
+
fillEl.style.transition = 'none';
|
| 2227 |
+
fillEl.style.width = `${progress.toFixed(2)}%`;
|
| 2228 |
+
|
| 2229 |
+
void fillEl.offsetWidth;
|
| 2230 |
+
|
| 2231 |
+
fillEl.style.transition = 'width 2s linear';
|
| 2232 |
+
}
|
| 2233 |
+
} else {
|
| 2234 |
+
if (fillEl && program.time && program.time_end) {
|
| 2235 |
+
const nowSec = getJSTTimestamp();
|
| 2236 |
+
const duration = program.time_end - program.time;
|
| 2237 |
+
const elapsed = Math.max(0, nowSec - program.time);
|
| 2238 |
+
const progress = Math.min((elapsed / duration) * 100, 100);
|
| 2239 |
+
|
| 2240 |
+
if (fillEl.style.transition === 'none') {
|
| 2241 |
+
fillEl.style.transition = 'width 2s linear';
|
| 2242 |
+
}
|
| 2243 |
+
|
| 2244 |
+
fillEl.style.width = `${progress.toFixed(2)}%`;
|
| 2245 |
+
}
|
| 2246 |
+
}
|
| 2247 |
+
}
|
| 2248 |
+
|
| 2249 |
+
function startEPGUpdateInterval() {
|
| 2250 |
+
if (epgUpdateInterval) {
|
| 2251 |
+
clearInterval(epgUpdateInterval);
|
| 2252 |
+
epgUpdateInterval = null;
|
| 2253 |
+
}
|
| 2254 |
+
|
| 2255 |
+
updateEPGDisplay();
|
| 2256 |
+
updateCurrentTime();
|
| 2257 |
+
|
| 2258 |
+
epgUpdateInterval = setInterval(() => {
|
| 2259 |
+
updateEPGDisplay();
|
| 2260 |
+
updateCurrentTime();
|
| 2261 |
+
}, 2000);
|
| 2262 |
+
}
|
| 2263 |
+
|
| 2264 |
+
function updateCurrentTime() {
|
| 2265 |
+
const timeEl = document.getElementById('currentTime');
|
| 2266 |
+
if (!timeEl) return;
|
| 2267 |
+
|
| 2268 |
+
timeEl.textContent = formatJSTClock() + ' (JST)';
|
| 2269 |
+
}
|
| 2270 |
+
|
| 2271 |
+
function updateActiveChannel() {
|
| 2272 |
+
document.querySelectorAll('.channel-item').forEach(item => {
|
| 2273 |
+
item.classList.remove('active');
|
| 2274 |
+
if (currentChannel && item.dataset.channelNo === String(currentChannel.no)) {
|
| 2275 |
+
item.classList.add('active');
|
| 2276 |
+
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
| 2277 |
+
}
|
| 2278 |
+
});
|
| 2279 |
+
}
|
| 2280 |
+
|
| 2281 |
+
function playPreviousChannel() {
|
| 2282 |
+
if (filteredChannels.length === 0) return;
|
| 2283 |
+
|
| 2284 |
+
let newIndex = currentChannelIndex - 1;
|
| 2285 |
+
if (newIndex < 0) newIndex = filteredChannels.length - 1;
|
| 2286 |
+
|
| 2287 |
+
const ch = filteredChannels[newIndex];
|
| 2288 |
+
if (ch) playChannel(ch.no);
|
| 2289 |
+
}
|
| 2290 |
+
|
| 2291 |
+
function playNextChannel() {
|
| 2292 |
+
if (filteredChannels.length === 0) return;
|
| 2293 |
+
|
| 2294 |
+
let newIndex = currentChannelIndex + 1;
|
| 2295 |
+
if (newIndex >= filteredChannels.length) newIndex = 0;
|
| 2296 |
+
|
| 2297 |
+
const ch = filteredChannels[newIndex];
|
| 2298 |
+
if (ch) playChannel(ch.no);
|
| 2299 |
+
}
|
| 2300 |
+
|
| 2301 |
+
async function toggleRecord() {
|
| 2302 |
+
const btn = document.getElementById('recordBtn');
|
| 2303 |
+
const status = document.getElementById('recordStatus');
|
| 2304 |
+
|
| 2305 |
+
if (recorder.active) {
|
| 2306 |
+
btn.disabled = true;
|
| 2307 |
+
btn.innerHTML = '<span class="btn-icon">⏳</span><span class="btn-text">停止中...</span>';
|
| 2308 |
+
status.textContent = '⏳ 合并中...';
|
| 2309 |
+
status.className = 'record-status-mini show';
|
| 2310 |
+
|
| 2311 |
+
const result = await recorder.stop();
|
| 2312 |
+
downloadRecording(result);
|
| 2313 |
+
|
| 2314 |
+
btn.innerHTML = '<span class="btn-icon">⏺</span><span class="btn-text">开始</span>';
|
| 2315 |
+
btn.disabled = false;
|
| 2316 |
+
|
| 2317 |
+
status.textContent = `✅ 完成!${result.segments}片段 | ${fmt.time(parseFloat(result.duration))} | ${fmt.bytes(result.size)}`;
|
| 2318 |
+
status.className = 'record-status-mini success show';
|
| 2319 |
+
|
| 2320 |
+
setTimeout(() => status.className = 'record-status-mini', 10000);
|
| 2321 |
+
return;
|
| 2322 |
+
}
|
| 2323 |
+
|
| 2324 |
+
if (!currentChannel) {
|
| 2325 |
+
return;
|
| 2326 |
+
}
|
| 2327 |
+
|
| 2328 |
+
btn.innerHTML = '<span class="btn-icon">⏹</span><span class="btn-text">停止</span>';
|
| 2329 |
+
status.textContent = '⏺ 准备中...';
|
| 2330 |
+
status.className = 'record-status-mini recording show';
|
| 2331 |
+
|
| 2332 |
+
try {
|
| 2333 |
+
const url = `${API}/stream/live/${currentChannel.no}.m3u8`;
|
| 2334 |
+
|
| 2335 |
+
await recorder.start(url, {
|
| 2336 |
+
onProgress: p => {
|
| 2337 |
+
status.textContent = `⏺ ${fmt.time(p.duration)} | ${p.segments}片段 | ${fmt.bytes(p.size)}`;
|
| 2338 |
+
status.className = 'record-status-mini recording show';
|
| 2339 |
+
},
|
| 2340 |
+
onError: e => {
|
| 2341 |
+
btn.innerHTML = '<span class="btn-icon">⏺</span><span class="btn-text">开始</span>';
|
| 2342 |
+
btn.disabled = false;
|
| 2343 |
+
status.textContent = `❌ 失败: ${e.message}`;
|
| 2344 |
+
status.className = 'record-status-mini error show';
|
| 2345 |
+
setTimeout(() => status.className = 'record-status-mini', 5000);
|
| 2346 |
+
}
|
| 2347 |
+
});
|
| 2348 |
+
|
| 2349 |
+
} catch (e) {
|
| 2350 |
+
btn.innerHTML = '<span class="btn-icon">⏺</span><span class="btn-text">开始</span>';
|
| 2351 |
+
btn.disabled = false;
|
| 2352 |
+
status.textContent = `❌ ${e.message}`;
|
| 2353 |
+
status.className = 'record-status-mini error show';
|
| 2354 |
+
setTimeout(() => status.className = 'record-status-mini', 5000);
|
| 2355 |
+
}
|
| 2356 |
+
}
|
| 2357 |
+
|
| 2358 |
+
function downloadRecording(result) {
|
| 2359 |
+
const jst = getJSTNow();
|
| 2360 |
+
const ts = `${jst.getFullYear()}${String(jst.getMonth()+1).padStart(2,'0')}${String(jst.getDate()).padStart(2,'0')}_${String(jst.getHours()).padStart(2,'0')}${String(jst.getMinutes()).padStart(2,'0')}`;
|
| 2361 |
+
|
| 2362 |
+
let name = 'live_recording';
|
| 2363 |
+
if (currentChannel) {
|
| 2364 |
+
const safe = currentChannel.name.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_');
|
| 2365 |
+
name = `live_${ts}_${safe}`;
|
| 2366 |
+
} else {
|
| 2367 |
+
name = `live_${ts}`;
|
| 2368 |
+
}
|
| 2369 |
+
|
| 2370 |
+
name += '.ts';
|
| 2371 |
+
|
| 2372 |
+
const a = document.createElement('a');
|
| 2373 |
+
a.href = result.url;
|
| 2374 |
+
a.download = name;
|
| 2375 |
+
a.style.display = 'none';
|
| 2376 |
+
document.body.appendChild(a);
|
| 2377 |
+
a.click();
|
| 2378 |
+
|
| 2379 |
+
setTimeout(() => {
|
| 2380 |
+
document.body.removeChild(a);
|
| 2381 |
+
URL.revokeObjectURL(result.url);
|
| 2382 |
+
}, 100);
|
| 2383 |
+
}
|
| 2384 |
+
|
| 2385 |
+
function toggleSidebar() {
|
| 2386 |
+
const layout = document.querySelector('.mdl-player-layout');
|
| 2387 |
+
const expandBtn = document.getElementById('expandBtn');
|
| 2388 |
+
const collapseBtn = document.getElementById('collapseBtn');
|
| 2389 |
+
|
| 2390 |
+
if (layout) {
|
| 2391 |
+
const isHidden = layout.classList.toggle('sidebar-hidden');
|
| 2392 |
+
|
| 2393 |
+
if (expandBtn) {
|
| 2394 |
+
expandBtn.style.display = isHidden ? 'block' : 'none';
|
| 2395 |
+
}
|
| 2396 |
+
|
| 2397 |
+
if (collapseBtn) {
|
| 2398 |
+
collapseBtn.textContent = isHidden ? '▶' : '◀';
|
| 2399 |
+
collapseBtn.title = isHidden ? '展开频道列表' : '收起侧边栏';
|
| 2400 |
+
}
|
| 2401 |
+
}
|
| 2402 |
+
}
|
| 2403 |
+
|
| 2404 |
+
function checkAutoPlay() {
|
| 2405 |
+
const channelNo = sessionStorage.getItem('player_channel');
|
| 2406 |
+
const autoPlay = sessionStorage.getItem('player_autoplay') === 'true';
|
| 2407 |
+
|
| 2408 |
+
if (channelNo) {
|
| 2409 |
+
sessionStorage.removeItem('player_channel');
|
| 2410 |
+
sessionStorage.removeItem('player_autoplay');
|
| 2411 |
+
|
| 2412 |
+
const checkInterval = setInterval(() => {
|
| 2413 |
+
if (channels.length > 0) {
|
| 2414 |
+
clearInterval(checkInterval);
|
| 2415 |
+
|
| 2416 |
+
if (autoPlay) {
|
| 2417 |
+
setTimeout(() => {
|
| 2418 |
+
playChannel(channelNo);
|
| 2419 |
+
}, 300);
|
| 2420 |
+
}
|
| 2421 |
+
}
|
| 2422 |
+
}, 100);
|
| 2423 |
+
|
| 2424 |
+
setTimeout(() => {
|
| 2425 |
+
clearInterval(checkInterval);
|
| 2426 |
+
}, 5000);
|
| 2427 |
+
}
|
| 2428 |
+
}
|
| 2429 |
+
|
| 2430 |
+
function setupEventListeners() {
|
| 2431 |
+
document.querySelectorAll('.category-tab').forEach(btn => {
|
| 2432 |
+
btn.addEventListener('click', () => {
|
| 2433 |
+
document.querySelectorAll('.category-tab').forEach(b => b.classList.remove('active'));
|
| 2434 |
+
btn.classList.add('active');
|
| 2435 |
+
|
| 2436 |
+
const category = btn.dataset.category;
|
| 2437 |
+
filterChannels(category);
|
| 2438 |
+
renderChannelList();
|
| 2439 |
+
});
|
| 2440 |
+
});
|
| 2441 |
+
|
| 2442 |
+
const searchInput = document.getElementById('channelSearch');
|
| 2443 |
+
if (searchInput) {
|
| 2444 |
+
let searchTimeout;
|
| 2445 |
+
searchInput.addEventListener('input', () => {
|
| 2446 |
+
clearTimeout(searchTimeout);
|
| 2447 |
+
searchTimeout = setTimeout(() => {
|
| 2448 |
+
filterChannels(currentCategory);
|
| 2449 |
+
renderChannelList();
|
| 2450 |
+
}, 300);
|
| 2451 |
+
});
|
| 2452 |
+
}
|
| 2453 |
+
|
| 2454 |
+
const channelList = document.getElementById('channelList');
|
| 2455 |
+
if (channelList) {
|
| 2456 |
+
channelList.addEventListener('click', (e) => {
|
| 2457 |
+
const item = e.target.closest('.channel-item');
|
| 2458 |
+
if (!item) return;
|
| 2459 |
+
|
| 2460 |
+
const channelNo = item.dataset.channelNo;
|
| 2461 |
+
if (channelNo) playChannel(channelNo);
|
| 2462 |
+
});
|
| 2463 |
+
}
|
| 2464 |
+
|
| 2465 |
+
const collapseBtn = document.getElementById('collapseBtn');
|
| 2466 |
+
if (collapseBtn) {
|
| 2467 |
+
collapseBtn.addEventListener('click', toggleSidebar);
|
| 2468 |
+
}
|
| 2469 |
+
|
| 2470 |
+
const expandBtn = document.getElementById('expandBtn');
|
| 2471 |
+
if (expandBtn) {
|
| 2472 |
+
expandBtn.addEventListener('click', toggleSidebar);
|
| 2473 |
+
}
|
| 2474 |
+
|
| 2475 |
+
const recordBtn = document.getElementById('recordBtn');
|
| 2476 |
+
if (recordBtn) recordBtn.addEventListener('click', toggleRecord);
|
| 2477 |
+
|
| 2478 |
+
document.addEventListener('keydown', (e) => {
|
| 2479 |
+
if (e.target.tagName === 'INPUT') return;
|
| 2480 |
+
|
| 2481 |
+
switch(e.key) {
|
| 2482 |
+
case 'ArrowUp':
|
| 2483 |
+
e.preventDefault();
|
| 2484 |
+
playPreviousChannel();
|
| 2485 |
+
break;
|
| 2486 |
+
case 'ArrowDown':
|
| 2487 |
+
e.preventDefault();
|
| 2488 |
+
playNextChannel();
|
| 2489 |
+
break;
|
| 2490 |
+
case 'f':
|
| 2491 |
+
case 'F':
|
| 2492 |
+
e.preventDefault();
|
| 2493 |
+
if (dp) {
|
| 2494 |
+
dp.fullScreen.toggle();
|
| 2495 |
+
}
|
| 2496 |
+
break;
|
| 2497 |
+
}
|
| 2498 |
+
});
|
| 2499 |
+
}
|
| 2500 |
+
|
| 2501 |
+
window.initPlayerPage = async function() {
|
| 2502 |
+
|
| 2503 |
+
const username = getCurrentUsername();
|
| 2504 |
+
if (username && window.userDataSync) {
|
| 2505 |
+
window.userDataSync.init(username);
|
| 2506 |
+
}
|
| 2507 |
+
loadFavorites();
|
| 2508 |
+
loadChannels();
|
| 2509 |
+
|
| 2510 |
+
|
| 2511 |
+
setTimeout(() => {
|
| 2512 |
+
const success = initPlayer();
|
| 2513 |
+
if (!success) {
|
| 2514 |
+
setTimeout(initPlayer, 1000);
|
| 2515 |
+
}
|
| 2516 |
+
}, 100);
|
| 2517 |
+
|
| 2518 |
+
setupEventListeners();
|
| 2519 |
+
checkAutoPlay();
|
| 2520 |
+
|
| 2521 |
+
updateCurrentTime();
|
| 2522 |
+
setInterval(updateCurrentTime, 1000);
|
| 2523 |
+
};
|
| 2524 |
+
|
| 2525 |
+
setTimeout(window.initPlayerPage, 0);
|
| 2526 |
+
|
| 2527 |
+
window.addEventListener('beforeunload', (e) => {
|
| 2528 |
+
if (recorder.active) {
|
| 2529 |
+
e.preventDefault();
|
| 2530 |
+
e.returnValue = '正在录制中,确定离开吗?';
|
| 2531 |
+
return e.returnValue;
|
| 2532 |
+
}
|
| 2533 |
+
|
| 2534 |
+
if (dp) {
|
| 2535 |
+
try {
|
| 2536 |
+
dp.destroy();
|
| 2537 |
+
} catch (e) {
|
| 2538 |
+
}
|
| 2539 |
+
dp = null;
|
| 2540 |
+
}
|
| 2541 |
+
|
| 2542 |
+
if (epgUpdateInterval) {
|
| 2543 |
+
clearInterval(epgUpdateInterval);
|
| 2544 |
+
epgUpdateInterval = null;
|
| 2545 |
+
}
|
| 2546 |
+
|
| 2547 |
+
if (sidebarEpgInterval) {
|
| 2548 |
+
clearInterval(sidebarEpgInterval);
|
| 2549 |
+
sidebarEpgInterval = null;
|
| 2550 |
+
}
|
| 2551 |
+
});
|
| 2552 |
+
|
| 2553 |
+
document.addEventListener('visibilitychange', () => {
|
| 2554 |
+
if (document.hidden) {
|
| 2555 |
+
if (sidebarEpgInterval) {
|
| 2556 |
+
clearInterval(sidebarEpgInterval);
|
| 2557 |
+
}
|
| 2558 |
+
} else {
|
| 2559 |
+
if (channels.length > 0) {
|
| 2560 |
+
startSidebarEpgUpdate();
|
| 2561 |
+
}
|
| 2562 |
+
}
|
| 2563 |
+
});
|
| 2564 |
+
|
| 2565 |
+
})();
|
| 2566 |
+
</script>
|
user_manager.py
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import secrets
|
| 2 |
+
import hashlib
|
| 3 |
+
import json
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from typing import Dict, List, Optional
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
from upstash_redis import Redis
|
| 8 |
+
import os
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
load_dotenv()
|
| 12 |
+
|
| 13 |
+
AVAILABLE_BADGES = {
|
| 14 |
+
'vip_dog': {
|
| 15 |
+
'id': 'vip_dog',
|
| 16 |
+
'name': '尊贵狗牌',
|
| 17 |
+
'icon': '🏷️',
|
| 18 |
+
'gradient': 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)',
|
| 19 |
+
'color': '#8B4513',
|
| 20 |
+
'border': '#FFD700',
|
| 21 |
+
'glow': 'rgba(255, 215, 0, 0.5)'
|
| 22 |
+
},
|
| 23 |
+
'diamond': {
|
| 24 |
+
'id': 'diamond',
|
| 25 |
+
'name': '钻石会员',
|
| 26 |
+
'icon': '💎',
|
| 27 |
+
'gradient': 'linear-gradient(135deg, #B9F2FF 0%, #00D4FF 100%)',
|
| 28 |
+
'color': '#003D5C',
|
| 29 |
+
'border': '#00D4FF',
|
| 30 |
+
'glow': 'rgba(0, 212, 255, 0.5)'
|
| 31 |
+
},
|
| 32 |
+
'crown': {
|
| 33 |
+
'id': 'crown',
|
| 34 |
+
'name': '皇冠用户',
|
| 35 |
+
'icon': '👑',
|
| 36 |
+
'gradient': 'linear-gradient(135deg, #FFE66D 0%, #FFB800 100%)',
|
| 37 |
+
'color': '#8B4000',
|
| 38 |
+
'border': '#FFB800',
|
| 39 |
+
'glow': 'rgba(255, 184, 0, 0.5)'
|
| 40 |
+
},
|
| 41 |
+
'star': {
|
| 42 |
+
'id': 'star',
|
| 43 |
+
'name': '星标用户',
|
| 44 |
+
'icon': '⭐',
|
| 45 |
+
'gradient': 'linear-gradient(135deg, #FFF7A5 0%, #FFDF00 100%)',
|
| 46 |
+
'color': '#8B7500',
|
| 47 |
+
'border': '#FFDF00',
|
| 48 |
+
'glow': 'rgba(255, 223, 0, 0.5)'
|
| 49 |
+
},
|
| 50 |
+
'fire': {
|
| 51 |
+
'id': 'fire',
|
| 52 |
+
'name': '火焰用户',
|
| 53 |
+
'icon': '🔥',
|
| 54 |
+
'gradient': 'linear-gradient(135deg, #FF6B6B 0%, #EE5A24 100%)',
|
| 55 |
+
'color': '#8B0000',
|
| 56 |
+
'border': '#EE5A24',
|
| 57 |
+
'glow': 'rgba(238, 90, 36, 0.5)'
|
| 58 |
+
},
|
| 59 |
+
'rocket': {
|
| 60 |
+
'id': 'rocket',
|
| 61 |
+
'name': '火箭用户',
|
| 62 |
+
'icon': '🚀',
|
| 63 |
+
'gradient': 'linear-gradient(135deg, #4ECDC4 0%, #44A08D 100%)',
|
| 64 |
+
'color': '#0D4C4A',
|
| 65 |
+
'border': '#44A08D',
|
| 66 |
+
'glow': 'rgba(68, 160, 141, 0.5)'
|
| 67 |
+
},
|
| 68 |
+
'rainbow': {
|
| 69 |
+
'id': 'rainbow',
|
| 70 |
+
'name': '彩虹用户',
|
| 71 |
+
'icon': '🌈',
|
| 72 |
+
'gradient': 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
|
| 73 |
+
'color': '#4A148C',
|
| 74 |
+
'border': '#764ba2',
|
| 75 |
+
'glow': 'rgba(118, 75, 162, 0.5)'
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
class User(BaseModel):
|
| 80 |
+
# 基本信息
|
| 81 |
+
username: str
|
| 82 |
+
password_hash: str
|
| 83 |
+
created_at: datetime
|
| 84 |
+
expires_at: Optional[datetime] = None
|
| 85 |
+
last_login: Optional[datetime] = None
|
| 86 |
+
is_active: bool = True
|
| 87 |
+
is_admin: bool = False
|
| 88 |
+
created_by: str = "admin"
|
| 89 |
+
notes: str = ""
|
| 90 |
+
badge: Optional[str] = None
|
| 91 |
+
|
| 92 |
+
# ✅ 用户设置(合并到用户模型)
|
| 93 |
+
favorite_channels: List[str] = []
|
| 94 |
+
download_concurrency: int = 16
|
| 95 |
+
batch_download_concurrency: int = 3
|
| 96 |
+
fab_position: Dict[str, float] = {'bottom': 30, 'right': 30}
|
| 97 |
+
playback_history: List[Dict] = []
|
| 98 |
+
program_reminders: List[Dict] = []
|
| 99 |
+
|
| 100 |
+
class Config:
|
| 101 |
+
json_encoders = {
|
| 102 |
+
datetime: lambda v: v.isoformat() if v else None
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
class UserManager:
|
| 106 |
+
def __init__(self):
|
| 107 |
+
redis_url = os.getenv('REDIS_URL', '')
|
| 108 |
+
redis_token = os.getenv('REDIS_TOKEN', '')
|
| 109 |
+
|
| 110 |
+
if not redis_url or not redis_token:
|
| 111 |
+
self.redis = None
|
| 112 |
+
self.users: Dict[str, User] = {}
|
| 113 |
+
else:
|
| 114 |
+
try:
|
| 115 |
+
self.redis = Redis(url=redis_url, token=redis_token)
|
| 116 |
+
self.redis.ping()
|
| 117 |
+
self.users: Dict[str, User] = {}
|
| 118 |
+
self.load_all_users()
|
| 119 |
+
except Exception as e:
|
| 120 |
+
self.redis = None
|
| 121 |
+
self.users: Dict[str, User] = {}
|
| 122 |
+
|
| 123 |
+
def _get_user_key(self, username: str) -> str:
|
| 124 |
+
return f"user:{username}"
|
| 125 |
+
|
| 126 |
+
def _save_user_to_redis(self, user: User):
|
| 127 |
+
"""保存用户到 Redis(包含所有设置)"""
|
| 128 |
+
if not self.redis:
|
| 129 |
+
return
|
| 130 |
+
|
| 131 |
+
try:
|
| 132 |
+
user_dict = user.dict()
|
| 133 |
+
|
| 134 |
+
# 转换 datetime
|
| 135 |
+
user_dict['created_at'] = user_dict['created_at'].isoformat()
|
| 136 |
+
if user_dict['expires_at']:
|
| 137 |
+
user_dict['expires_at'] = user_dict['expires_at'].isoformat()
|
| 138 |
+
if user_dict['last_login']:
|
| 139 |
+
user_dict['last_login'] = user_dict['last_login'].isoformat()
|
| 140 |
+
|
| 141 |
+
user_json = json.dumps(user_dict)
|
| 142 |
+
self.redis.set(self._get_user_key(user.username), user_json)
|
| 143 |
+
self.redis.sadd("users:all", user.username)
|
| 144 |
+
|
| 145 |
+
print(f"✅ 用户 {user.username} 已保存到 Redis(包含所有设置)")
|
| 146 |
+
except Exception as e:
|
| 147 |
+
print(f"❌ 保存用户失败: {e}")
|
| 148 |
+
|
| 149 |
+
def _save_user_to_redis_with_retry(self, user: User, max_retries: int = 3):
|
| 150 |
+
"""带重试机制的Redis保存"""
|
| 151 |
+
if not self.redis:
|
| 152 |
+
print(f"⚠️ Redis不可用,���过保存用户 {user.username}")
|
| 153 |
+
return False
|
| 154 |
+
|
| 155 |
+
for attempt in range(max_retries):
|
| 156 |
+
try:
|
| 157 |
+
user_dict = user.dict()
|
| 158 |
+
|
| 159 |
+
# 转换 datetime
|
| 160 |
+
user_dict['created_at'] = user_dict['created_at'].isoformat()
|
| 161 |
+
if user_dict['expires_at']:
|
| 162 |
+
user_dict['expires_at'] = user_dict['expires_at'].isoformat()
|
| 163 |
+
if user_dict['last_login']:
|
| 164 |
+
user_dict['last_login'] = user_dict['last_login'].isoformat()
|
| 165 |
+
|
| 166 |
+
user_json = json.dumps(user_dict)
|
| 167 |
+
self.redis.set(self._get_user_key(user.username), user_json)
|
| 168 |
+
self.redis.sadd("users:all", user.username)
|
| 169 |
+
|
| 170 |
+
print(f"✅ 用户 {user.username} 已保存到 Redis(重试第 {attempt + 1} 次成功)")
|
| 171 |
+
return True
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
print(f"❌ 保存用户失败(第 {attempt + 1} 次重试): {e}")
|
| 175 |
+
if attempt == max_retries - 1:
|
| 176 |
+
print(f"❌ 用户 {user.username} 保存到 Redis 失败,已达最大重试次数")
|
| 177 |
+
return False
|
| 178 |
+
import time
|
| 179 |
+
time.sleep(0.5 * (attempt + 1)) # 指数退避
|
| 180 |
+
|
| 181 |
+
return False
|
| 182 |
+
|
| 183 |
+
def _load_user_from_redis(self, username: str) -> Optional[User]:
|
| 184 |
+
"""从 Redis 加载用户(包含所有设置)"""
|
| 185 |
+
if not self.redis:
|
| 186 |
+
return None
|
| 187 |
+
|
| 188 |
+
try:
|
| 189 |
+
user_json = self.redis.get(self._get_user_key(username))
|
| 190 |
+
if not user_json:
|
| 191 |
+
return None
|
| 192 |
+
|
| 193 |
+
user_dict = json.loads(user_json)
|
| 194 |
+
|
| 195 |
+
# 转换 datetime
|
| 196 |
+
user_dict['created_at'] = datetime.fromisoformat(user_dict['created_at'])
|
| 197 |
+
if user_dict.get('expires_at'):
|
| 198 |
+
user_dict['expires_at'] = datetime.fromisoformat(user_dict['expires_at'])
|
| 199 |
+
if user_dict.get('last_login'):
|
| 200 |
+
user_dict['last_login'] = datetime.fromisoformat(user_dict['last_login'])
|
| 201 |
+
|
| 202 |
+
# ✅ 兼容旧数据:如果没有新字段,使用默认值
|
| 203 |
+
if 'favorite_channels' not in user_dict:
|
| 204 |
+
user_dict['favorite_channels'] = []
|
| 205 |
+
if 'download_concurrency' not in user_dict:
|
| 206 |
+
user_dict['download_concurrency'] = 16
|
| 207 |
+
if 'batch_download_concurrency' not in user_dict:
|
| 208 |
+
user_dict['batch_download_concurrency'] = 3
|
| 209 |
+
if 'fab_position' not in user_dict:
|
| 210 |
+
user_dict['fab_position'] = {'bottom': 30, 'right': 30}
|
| 211 |
+
if 'playback_history' not in user_dict:
|
| 212 |
+
user_dict['playback_history'] = []
|
| 213 |
+
if 'program_reminders' not in user_dict:
|
| 214 |
+
user_dict['program_reminders'] = []
|
| 215 |
+
|
| 216 |
+
user = User(**user_dict)
|
| 217 |
+
return user
|
| 218 |
+
|
| 219 |
+
except Exception as e:
|
| 220 |
+
print(f"❌ 加载用户失败: {e}")
|
| 221 |
+
import traceback
|
| 222 |
+
traceback.print_exc()
|
| 223 |
+
return None
|
| 224 |
+
|
| 225 |
+
def load_all_users(self):
|
| 226 |
+
"""加载所有用户"""
|
| 227 |
+
if not self.redis:
|
| 228 |
+
return
|
| 229 |
+
|
| 230 |
+
try:
|
| 231 |
+
usernames = self.redis.smembers("users:all")
|
| 232 |
+
if not usernames:
|
| 233 |
+
return
|
| 234 |
+
|
| 235 |
+
for username in usernames:
|
| 236 |
+
user = self._load_user_from_redis(username)
|
| 237 |
+
if user:
|
| 238 |
+
self.users[username] = user
|
| 239 |
+
|
| 240 |
+
print(f"✅ 已加载 {len(self.users)} 个用户")
|
| 241 |
+
except Exception as e:
|
| 242 |
+
print(f"❌ 加载用户列表失败: {e}")
|
| 243 |
+
|
| 244 |
+
def _delete_user_from_redis(self, username: str):
|
| 245 |
+
"""从 Redis 删除用户"""
|
| 246 |
+
if not self.redis:
|
| 247 |
+
return
|
| 248 |
+
|
| 249 |
+
try:
|
| 250 |
+
self.redis.delete(self._get_user_key(username))
|
| 251 |
+
self.redis.srem("users:all", username)
|
| 252 |
+
except Exception as e:
|
| 253 |
+
pass
|
| 254 |
+
|
| 255 |
+
# ==================== 基本用户管理 ====================
|
| 256 |
+
|
| 257 |
+
def generate_password(self, length: int = 12) -> str:
|
| 258 |
+
chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
| 259 |
+
return ''.join(secrets.choice(chars) for _ in range(length))
|
| 260 |
+
|
| 261 |
+
def hash_password(self, password: str) -> str:
|
| 262 |
+
return hashlib.sha256(password.encode()).hexdigest()
|
| 263 |
+
|
| 264 |
+
def create_user(
|
| 265 |
+
self,
|
| 266 |
+
username: str,
|
| 267 |
+
password: Optional[str] = None,
|
| 268 |
+
expires_days: Optional[int] = None,
|
| 269 |
+
notes: str = "",
|
| 270 |
+
badge: Optional[str] = None,
|
| 271 |
+
is_admin: bool = False
|
| 272 |
+
) -> tuple[User, str]:
|
| 273 |
+
if username in self.users:
|
| 274 |
+
raise ValueError(f"User {username} already exists")
|
| 275 |
+
|
| 276 |
+
plain_password = password or self.generate_password()
|
| 277 |
+
password_hash = self.hash_password(plain_password)
|
| 278 |
+
|
| 279 |
+
expires_at = None
|
| 280 |
+
if expires_days:
|
| 281 |
+
expires_at = datetime.now() + timedelta(days=expires_days)
|
| 282 |
+
|
| 283 |
+
if badge and badge not in AVAILABLE_BADGES:
|
| 284 |
+
raise ValueError(f"Invalid badge: {badge}")
|
| 285 |
+
|
| 286 |
+
user = User(
|
| 287 |
+
username=username,
|
| 288 |
+
password_hash=password_hash,
|
| 289 |
+
created_at=datetime.now(),
|
| 290 |
+
expires_at=expires_at,
|
| 291 |
+
notes=notes,
|
| 292 |
+
badge=badge,
|
| 293 |
+
is_admin=is_admin
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
self.users[username] = user
|
| 297 |
+
# 实时保存到Redis
|
| 298 |
+
self._save_user_to_redis_with_retry(user)
|
| 299 |
+
|
| 300 |
+
return user, plain_password
|
| 301 |
+
|
| 302 |
+
def verify_user(self, username: str, password_hash: str) -> bool:
|
| 303 |
+
if username not in self.users:
|
| 304 |
+
user = self._load_user_from_redis(username)
|
| 305 |
+
if user:
|
| 306 |
+
self.users[username] = user
|
| 307 |
+
else:
|
| 308 |
+
return False
|
| 309 |
+
|
| 310 |
+
user = self.users[username]
|
| 311 |
+
|
| 312 |
+
if not user.is_active:
|
| 313 |
+
return False
|
| 314 |
+
|
| 315 |
+
if user.expires_at and datetime.now() > user.expires_at:
|
| 316 |
+
user.is_active = False
|
| 317 |
+
self._save_user_to_redis(user)
|
| 318 |
+
return False
|
| 319 |
+
|
| 320 |
+
if user.password_hash == password_hash:
|
| 321 |
+
user.last_login = datetime.now()
|
| 322 |
+
# 实时保存登录时间到Redis
|
| 323 |
+
self._save_user_to_redis_with_retry(user)
|
| 324 |
+
return True
|
| 325 |
+
|
| 326 |
+
return False
|
| 327 |
+
|
| 328 |
+
def delete_user(self, username: str) -> bool:
|
| 329 |
+
if username in self.users:
|
| 330 |
+
del self.users[username]
|
| 331 |
+
self._delete_user_from_redis(username)
|
| 332 |
+
return True
|
| 333 |
+
return False
|
| 334 |
+
|
| 335 |
+
def deactivate_user(self, username: str) -> bool:
|
| 336 |
+
if username in self.users:
|
| 337 |
+
self.users[username].is_active = False
|
| 338 |
+
# 实时保存状态变更到Redis
|
| 339 |
+
self._save_user_to_redis_with_retry(self.users[username])
|
| 340 |
+
return True
|
| 341 |
+
return False
|
| 342 |
+
|
| 343 |
+
def activate_user(self, username: str) -> bool:
|
| 344 |
+
if username in self.users:
|
| 345 |
+
self.users[username].is_active = True
|
| 346 |
+
# 实时保存状态变更到Redis
|
| 347 |
+
self._save_user_to_redis_with_retry(self.users[username])
|
| 348 |
+
return True
|
| 349 |
+
return False
|
| 350 |
+
|
| 351 |
+
def extend_expiry(self, username: str, days: int) -> bool:
|
| 352 |
+
if username in self.users:
|
| 353 |
+
user = self.users[username]
|
| 354 |
+
if user.expires_at:
|
| 355 |
+
user.expires_at += timedelta(days=days)
|
| 356 |
+
else:
|
| 357 |
+
user.expires_at = datetime.now() + timedelta(days=days)
|
| 358 |
+
# 实时保存过期时间变更到Redis
|
| 359 |
+
self._save_user_to_redis_with_retry(user)
|
| 360 |
+
return True
|
| 361 |
+
return False
|
| 362 |
+
|
| 363 |
+
def set_badge(self, username: str, badge: Optional[str]) -> bool:
|
| 364 |
+
if username not in self.users:
|
| 365 |
+
user = self._load_user_from_redis(username)
|
| 366 |
+
if user:
|
| 367 |
+
self.users[username] = user
|
| 368 |
+
else:
|
| 369 |
+
return False
|
| 370 |
+
|
| 371 |
+
if badge and badge not in AVAILABLE_BADGES:
|
| 372 |
+
raise ValueError(f"Invalid badge: {badge}")
|
| 373 |
+
|
| 374 |
+
self.users[username].badge = badge
|
| 375 |
+
# 实时保存徽章变更到Redis
|
| 376 |
+
self._save_user_to_redis_with_retry(self.users[username])
|
| 377 |
+
return True
|
| 378 |
+
|
| 379 |
+
# ==================== 用户设置管理(直接操作用户对象)====================
|
| 380 |
+
|
| 381 |
+
def get_user_data(self, username: str) -> Optional[Dict]:
|
| 382 |
+
"""获取用户完整数据(包含设置)"""
|
| 383 |
+
if username not in self.users:
|
| 384 |
+
user = self._load_user_from_redis(username)
|
| 385 |
+
if user:
|
| 386 |
+
self.users[username] = user
|
| 387 |
+
else:
|
| 388 |
+
return None
|
| 389 |
+
|
| 390 |
+
user = self.users[username]
|
| 391 |
+
return {
|
| 392 |
+
'favorite_channels': user.favorite_channels,
|
| 393 |
+
'download_concurrency': user.download_concurrency,
|
| 394 |
+
'batch_download_concurrency': user.batch_download_concurrency,
|
| 395 |
+
'fab_position': user.fab_position,
|
| 396 |
+
'playback_history': user.playback_history,
|
| 397 |
+
'program_reminders': user.program_reminders
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
def get_user_settings(self, username: str) -> Dict:
|
| 401 |
+
"""获取用户设置(兼容旧API)"""
|
| 402 |
+
data = self.get_user_data(username)
|
| 403 |
+
if data is None:
|
| 404 |
+
# 如果用户不存在,返回默认设置
|
| 405 |
+
return {
|
| 406 |
+
'favorite_channels': [],
|
| 407 |
+
'download_concurrency': 16,
|
| 408 |
+
'batch_download_concurrency': 3,
|
| 409 |
+
'fab_position': {'bottom': 30, 'right': 30},
|
| 410 |
+
'playback_history': [],
|
| 411 |
+
'program_reminders': []
|
| 412 |
+
}
|
| 413 |
+
return data
|
| 414 |
+
|
| 415 |
+
def delete_user_settings(self, username: str) -> bool:
|
| 416 |
+
"""删除用户设置(重置为默认值)"""
|
| 417 |
+
if username not in self.users:
|
| 418 |
+
user = self._load_user_from_redis(username)
|
| 419 |
+
if user:
|
| 420 |
+
self.users[username] = user
|
| 421 |
+
else:
|
| 422 |
+
return False
|
| 423 |
+
|
| 424 |
+
# 重置用户设置为默认值
|
| 425 |
+
user = self.users[username]
|
| 426 |
+
user.favorite_channels = []
|
| 427 |
+
user.download_concurrency = 16
|
| 428 |
+
user.batch_download_concurrency = 3
|
| 429 |
+
user.fab_position = {'bottom': 30, 'right': 30}
|
| 430 |
+
user.playback_history = []
|
| 431 |
+
user.program_reminders = []
|
| 432 |
+
|
| 433 |
+
# 实时保存到Redis
|
| 434 |
+
self._save_user_to_redis_with_retry(user)
|
| 435 |
+
print(f"✅ 用户 {username} 设置已重置为默认值")
|
| 436 |
+
return True
|
| 437 |
+
|
| 438 |
+
def update_user_data(self, username: str, data: Dict) -> bool:
|
| 439 |
+
"""更新用户数据(增量更新)"""
|
| 440 |
+
if username not in self.users:
|
| 441 |
+
user = self._load_user_from_redis(username)
|
| 442 |
+
if user:
|
| 443 |
+
self.users[username] = user
|
| 444 |
+
else:
|
| 445 |
+
return False
|
| 446 |
+
|
| 447 |
+
user = self.users[username]
|
| 448 |
+
|
| 449 |
+
# ✅ 只更新传入的字段
|
| 450 |
+
if 'favorite_channels' in data:
|
| 451 |
+
user.favorite_channels = data['favorite_channels']
|
| 452 |
+
if 'download_concurrency' in data:
|
| 453 |
+
user.download_concurrency = data['download_concurrency']
|
| 454 |
+
if 'batch_download_concurrency' in data:
|
| 455 |
+
user.batch_download_concurrency = data['batch_download_concurrency']
|
| 456 |
+
if 'fab_position' in data:
|
| 457 |
+
user.fab_position = data['fab_position']
|
| 458 |
+
if 'playback_history' in data:
|
| 459 |
+
user.playback_history = data['playback_history']
|
| 460 |
+
if 'program_reminders' in data:
|
| 461 |
+
user.program_reminders = data['program_reminders']
|
| 462 |
+
|
| 463 |
+
# 实时保存用户行为数据到Redis
|
| 464 |
+
self._save_user_to_redis_with_retry(user)
|
| 465 |
+
print(f"✅ 用户 {username} 数据已实时保存到Redis: {list(data.keys())}")
|
| 466 |
+
return True
|
| 467 |
+
|
| 468 |
+
# ==================== 便捷方法 ====================
|
| 469 |
+
|
| 470 |
+
def get_favorites(self, username: str) -> List[str]:
|
| 471 |
+
"""获取收藏频道"""
|
| 472 |
+
data = self.get_user_data(username)
|
| 473 |
+
return data['favorite_channels'] if data else []
|
| 474 |
+
|
| 475 |
+
def set_favorites(self, username: str, favorites: List[str]) -> bool:
|
| 476 |
+
"""设置收藏频道"""
|
| 477 |
+
return self.update_user_data(username, {'favorite_channels': favorites})
|
| 478 |
+
|
| 479 |
+
def get_download_concurrency(self, username: str) -> int:
|
| 480 |
+
"""获取下载并发数"""
|
| 481 |
+
data = self.get_user_data(username)
|
| 482 |
+
return data['download_concurrency'] if data else 16
|
| 483 |
+
|
| 484 |
+
def set_download_concurrency(self, username: str, concurrency: int) -> bool:
|
| 485 |
+
"""设置下载并发数"""
|
| 486 |
+
return self.update_user_data(username, {'download_concurrency': concurrency})
|
| 487 |
+
|
| 488 |
+
def get_batch_concurrency(self, username: str) -> int:
|
| 489 |
+
"""获取批量并发数"""
|
| 490 |
+
data = self.get_user_data(username)
|
| 491 |
+
return data['batch_download_concurrency'] if data else 3
|
| 492 |
+
|
| 493 |
+
def set_batch_concurrency(self, username: str, concurrency: int) -> bool:
|
| 494 |
+
"""设置批量并发数"""
|
| 495 |
+
return self.update_user_data(username, {'batch_download_concurrency': concurrency})
|
| 496 |
+
|
| 497 |
+
def get_fab_position(self, username: str) -> Dict[str, float]:
|
| 498 |
+
"""获取 FAB 位置"""
|
| 499 |
+
data = self.get_user_data(username)
|
| 500 |
+
return data['fab_position'] if data else {'bottom': 30, 'right': 30}
|
| 501 |
+
|
| 502 |
+
def set_fab_position(self, username: str, position: Dict[str, float]) -> bool:
|
| 503 |
+
"""设置 FAB 位置"""
|
| 504 |
+
return self.update_user_data(username, {'fab_position': position})
|
| 505 |
+
|
| 506 |
+
def get_user(self, username: str) -> Optional[User]:
|
| 507 |
+
if username in self.users:
|
| 508 |
+
return self.users[username]
|
| 509 |
+
|
| 510 |
+
user = self._load_user_from_redis(username)
|
| 511 |
+
if user:
|
| 512 |
+
self.users[username] = user
|
| 513 |
+
return user
|
| 514 |
+
|
| 515 |
+
def list_users(self) -> List[User]:
|
| 516 |
+
try:
|
| 517 |
+
if self.redis:
|
| 518 |
+
self.load_all_users()
|
| 519 |
+
|
| 520 |
+
users = list(self.users.values())
|
| 521 |
+
return users
|
| 522 |
+
except Exception as e:
|
| 523 |
+
import traceback
|
| 524 |
+
traceback.print_exc()
|
| 525 |
+
return []
|
| 526 |
+
|
| 527 |
+
def get_stats(self) -> dict:
|
| 528 |
+
try:
|
| 529 |
+
if self.redis:
|
| 530 |
+
self.load_all_users()
|
| 531 |
+
|
| 532 |
+
total = len(self.users)
|
| 533 |
+
active = sum(1 for u in self.users.values() if u.is_active)
|
| 534 |
+
expired = sum(1 for u in self.users.values()
|
| 535 |
+
if u.expires_at and datetime.now() > u.expires_at)
|
| 536 |
+
|
| 537 |
+
return {
|
| 538 |
+
"total": total,
|
| 539 |
+
"active": active,
|
| 540 |
+
"expired": expired,
|
| 541 |
+
"inactive": total - active,
|
| 542 |
+
"storage": "Redis (Upstash)" if self.redis else "Memory (临时)"
|
| 543 |
+
}
|
| 544 |
+
except Exception as e:
|
| 545 |
+
return {
|
| 546 |
+
"total": 0,
|
| 547 |
+
"active": 0,
|
| 548 |
+
"expired": 0,
|
| 549 |
+
"inactive": 0,
|
| 550 |
+
"storage": "Error"
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
def get_available_badges(self) -> dict:
|
| 554 |
+
return AVAILABLE_BADGES
|
| 555 |
+
|
| 556 |
+
user_manager = UserManager()
|
utils.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import httpx
|
| 3 |
+
from datetime import datetime, timedelta, timezone
|
| 4 |
+
from typing import Optional, Dict, Any
|
| 5 |
+
from urllib.parse import urlparse
|
| 6 |
+
from config import Config
|
| 7 |
+
from cache_manager import cache
|
| 8 |
+
|
| 9 |
+
async def get_cid(force: bool = False) -> str:
|
| 10 |
+
if not force:
|
| 11 |
+
cached = cache.get_cid()
|
| 12 |
+
if cached:
|
| 13 |
+
return cached
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
url = Config.get_cid_url()
|
| 17 |
+
|
| 18 |
+
async with httpx.AsyncClient(timeout=Config.TIMEOUT) as client:
|
| 19 |
+
response = await client.get(url)
|
| 20 |
+
response.raise_for_status()
|
| 21 |
+
data = response.json()
|
| 22 |
+
|
| 23 |
+
if 'cid' not in data:
|
| 24 |
+
raise ValueError("CID not found in response")
|
| 25 |
+
|
| 26 |
+
cid = data['cid']
|
| 27 |
+
cache.set_cid(cid)
|
| 28 |
+
return cid
|
| 29 |
+
|
| 30 |
+
except Exception as e:
|
| 31 |
+
if cache.cid:
|
| 32 |
+
return cache.cid
|
| 33 |
+
raise e
|
| 34 |
+
|
| 35 |
+
async def get_auth(force: bool = False, retry_count: int = 0) -> Dict[str, Any]:
|
| 36 |
+
if not force:
|
| 37 |
+
cached = cache.get_auth()
|
| 38 |
+
if cached:
|
| 39 |
+
return cached
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
cid = await get_cid(force=(retry_count > 0))
|
| 43 |
+
|
| 44 |
+
login_url = Config.get_login_url(cid)
|
| 45 |
+
|
| 46 |
+
async with httpx.AsyncClient(timeout=Config.TIMEOUT) as client:
|
| 47 |
+
response = await client.get(login_url)
|
| 48 |
+
response.raise_for_status()
|
| 49 |
+
data = response.json()
|
| 50 |
+
|
| 51 |
+
if data.get('code') != 'OK':
|
| 52 |
+
error_msg = data.get('message', 'Unknown error')
|
| 53 |
+
|
| 54 |
+
if 'cid' in error_msg.lower() and retry_count < 2:
|
| 55 |
+
return await get_auth(force=True, retry_count=retry_count + 1)
|
| 56 |
+
|
| 57 |
+
raise ValueError(f"Login failed: {error_msg}")
|
| 58 |
+
|
| 59 |
+
product_config = json.loads(data.get('product_config', '{}'))
|
| 60 |
+
|
| 61 |
+
auth = {
|
| 62 |
+
'access_token': data['access_token'],
|
| 63 |
+
'vms_host': product_config['vms_host'].rstrip('/'),
|
| 64 |
+
'vms_uid': product_config['vms_uid']
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
if not all(auth.values()):
|
| 68 |
+
raise ValueError("Incomplete auth data")
|
| 69 |
+
|
| 70 |
+
cache.set_auth(auth)
|
| 71 |
+
return auth
|
| 72 |
+
|
| 73 |
+
except Exception as e:
|
| 74 |
+
if cache.auth and retry_count == 0:
|
| 75 |
+
return cache.auth
|
| 76 |
+
raise e
|
| 77 |
+
|
| 78 |
+
async def get_channels(auth: Dict[str, Any], force: bool = False) -> list:
|
| 79 |
+
if not force:
|
| 80 |
+
cached = cache.get_channels()
|
| 81 |
+
if cached:
|
| 82 |
+
return cached
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
url = Config.get_list_url(auth['vms_uid'], with_epg=False)
|
| 86 |
+
|
| 87 |
+
headers = {
|
| 88 |
+
'Referer': Config.REQUIRED_REFERER,
|
| 89 |
+
'User-Agent': 'Mozilla/5.0'
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
async with httpx.AsyncClient(timeout=Config.TIMEOUT) as client:
|
| 93 |
+
response = await client.get(url, headers=headers)
|
| 94 |
+
response.raise_for_status()
|
| 95 |
+
data = response.json()
|
| 96 |
+
|
| 97 |
+
channels = [
|
| 98 |
+
ch for ch in data.get('result', [])
|
| 99 |
+
if ch.get('id') and ch.get('no') and ch.get('name') and ch.get('playpath')
|
| 100 |
+
]
|
| 101 |
+
|
| 102 |
+
if not channels:
|
| 103 |
+
raise ValueError("No channels found")
|
| 104 |
+
|
| 105 |
+
cache.set_channels(channels)
|
| 106 |
+
return channels
|
| 107 |
+
|
| 108 |
+
except httpx.HTTPStatusError as e:
|
| 109 |
+
if e.response.status_code in [401, 403]:
|
| 110 |
+
new_auth = await get_auth(force=True)
|
| 111 |
+
return await get_channels(new_auth, force=True)
|
| 112 |
+
raise e
|
| 113 |
+
|
| 114 |
+
except Exception as e:
|
| 115 |
+
if cache.channels:
|
| 116 |
+
return cache.channels
|
| 117 |
+
raise e
|
| 118 |
+
|
| 119 |
+
async def fetch_epg(vid: str, date: str, auth: dict, retry_count: int = 0) -> list:
|
| 120 |
+
"""获取EPG数据,优先从缓存读取"""
|
| 121 |
+
# 先检查缓存
|
| 122 |
+
cached = cache.get_epg(vid, date)
|
| 123 |
+
if cached is not None:
|
| 124 |
+
return cached
|
| 125 |
+
|
| 126 |
+
# 缓存未命中,从API获取
|
| 127 |
+
try:
|
| 128 |
+
url = Config.get_epg_url(auth['vms_uid'], vid)
|
| 129 |
+
|
| 130 |
+
headers = {
|
| 131 |
+
'Referer': Config.REQUIRED_REFERER,
|
| 132 |
+
'User-Agent': 'Mozilla/5.0'
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
async with httpx.AsyncClient(timeout=Config.TIMEOUT) as client:
|
| 136 |
+
response = await client.get(url, headers=headers)
|
| 137 |
+
|
| 138 |
+
if response.status_code in [401, 403] and retry_count < 2:
|
| 139 |
+
new_auth = await get_auth(force=True)
|
| 140 |
+
return await fetch_epg(vid, date, new_auth, retry_count + 1)
|
| 141 |
+
|
| 142 |
+
response.raise_for_status()
|
| 143 |
+
data = response.json()
|
| 144 |
+
|
| 145 |
+
if not data.get('result') or not data['result'][0].get('record_epg'):
|
| 146 |
+
# 空数据也缓存
|
| 147 |
+
cache.set_epg(vid, date, [])
|
| 148 |
+
return []
|
| 149 |
+
|
| 150 |
+
full_epg = json.loads(data['result'][0]['record_epg'])
|
| 151 |
+
|
| 152 |
+
# 处理节目数据
|
| 153 |
+
processed_epg = []
|
| 154 |
+
for i, program in enumerate(full_epg):
|
| 155 |
+
if not program.get('time'):
|
| 156 |
+
continue
|
| 157 |
+
|
| 158 |
+
if 'time_end' not in program or not program['time_end']:
|
| 159 |
+
if i + 1 < len(full_epg) and full_epg[i + 1].get('time'):
|
| 160 |
+
program['time_end'] = full_epg[i + 1]['time']
|
| 161 |
+
else:
|
| 162 |
+
continue
|
| 163 |
+
|
| 164 |
+
processed_epg.append(program)
|
| 165 |
+
|
| 166 |
+
# 按天分组缓存
|
| 167 |
+
daily_epg = {}
|
| 168 |
+
for program in processed_epg:
|
| 169 |
+
dt = datetime.fromtimestamp(program['time'])
|
| 170 |
+
date_str = get_jst_date(dt)
|
| 171 |
+
|
| 172 |
+
if date_str not in daily_epg:
|
| 173 |
+
daily_epg[date_str] = []
|
| 174 |
+
daily_epg[date_str].append(program)
|
| 175 |
+
|
| 176 |
+
# 缓存所有日期的数据
|
| 177 |
+
for d, programs in daily_epg.items():
|
| 178 |
+
sorted_programs = sorted(programs, key=lambda x: x['time'])
|
| 179 |
+
cache.set_epg(vid, d, sorted_programs)
|
| 180 |
+
|
| 181 |
+
# 返回请求的日期数据
|
| 182 |
+
result = daily_epg.get(date, [])
|
| 183 |
+
if result:
|
| 184 |
+
return sorted(result, key=lambda x: x['time'])
|
| 185 |
+
else:
|
| 186 |
+
# 如果请求的日期没有数据,也缓存空结果
|
| 187 |
+
if date not in daily_epg:
|
| 188 |
+
cache.set_epg(vid, date, [])
|
| 189 |
+
return []
|
| 190 |
+
|
| 191 |
+
except Exception as e:
|
| 192 |
+
raise e
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
async def get_all_epg(auth: Dict[str, Any], force: bool = False) -> Dict[str, list]:
|
| 196 |
+
"""获取所有频道的EPG数据,优先使用缓存"""
|
| 197 |
+
# 检查全量缓存
|
| 198 |
+
if not force:
|
| 199 |
+
cached = cache.get_epg('_all_', 'full')
|
| 200 |
+
if cached:
|
| 201 |
+
return cached
|
| 202 |
+
|
| 203 |
+
# 从API获取全量数据
|
| 204 |
+
try:
|
| 205 |
+
url = Config.get_list_url(auth['vms_uid'], with_epg=True)
|
| 206 |
+
|
| 207 |
+
headers = {
|
| 208 |
+
'Referer': Config.REQUIRED_REFERER,
|
| 209 |
+
'User-Agent': 'Mozilla/5.0'
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
async with httpx.AsyncClient(timeout=Config.TIMEOUT) as client:
|
| 213 |
+
response = await client.get(url, headers=headers)
|
| 214 |
+
response.raise_for_status()
|
| 215 |
+
data = response.json()
|
| 216 |
+
|
| 217 |
+
result = {}
|
| 218 |
+
|
| 219 |
+
for channel in data.get('result', []):
|
| 220 |
+
channel_id = channel.get('id')
|
| 221 |
+
record_epg = channel.get('record_epg')
|
| 222 |
+
|
| 223 |
+
if not channel_id:
|
| 224 |
+
continue
|
| 225 |
+
|
| 226 |
+
if not record_epg:
|
| 227 |
+
result[channel_id] = []
|
| 228 |
+
continue
|
| 229 |
+
|
| 230 |
+
try:
|
| 231 |
+
epg_list = json.loads(record_epg)
|
| 232 |
+
|
| 233 |
+
processed_programs = []
|
| 234 |
+
for i, program in enumerate(epg_list):
|
| 235 |
+
if not program.get('time'):
|
| 236 |
+
continue
|
| 237 |
+
|
| 238 |
+
if 'time_end' not in program or not program['time_end']:
|
| 239 |
+
if i + 1 < len(epg_list) and epg_list[i + 1].get('time'):
|
| 240 |
+
program['time_end'] = epg_list[i + 1]['time']
|
| 241 |
+
else:
|
| 242 |
+
continue
|
| 243 |
+
|
| 244 |
+
processed_programs.append(program)
|
| 245 |
+
|
| 246 |
+
# 按天分组缓存
|
| 247 |
+
daily_epg = {}
|
| 248 |
+
for program in processed_programs:
|
| 249 |
+
dt = datetime.fromtimestamp(program['time'])
|
| 250 |
+
date_str = get_jst_date(dt)
|
| 251 |
+
|
| 252 |
+
if date_str not in daily_epg:
|
| 253 |
+
daily_epg[date_str] = []
|
| 254 |
+
daily_epg[date_str].append(program)
|
| 255 |
+
|
| 256 |
+
# 缓存每一天的数据
|
| 257 |
+
for date, programs in daily_epg.items():
|
| 258 |
+
sorted_programs = sorted(programs, key=lambda x: x['time'])
|
| 259 |
+
cache.set_epg(channel_id, date, sorted_programs)
|
| 260 |
+
|
| 261 |
+
result[channel_id] = processed_programs
|
| 262 |
+
|
| 263 |
+
except json.JSONDecodeError:
|
| 264 |
+
result[channel_id] = []
|
| 265 |
+
continue
|
| 266 |
+
|
| 267 |
+
# 缓存全量数据
|
| 268 |
+
cache.set_epg('_all_', 'full', result)
|
| 269 |
+
|
| 270 |
+
return result
|
| 271 |
+
|
| 272 |
+
except Exception as e:
|
| 273 |
+
# 如果有缓存,返回缓存
|
| 274 |
+
cached = cache.get_epg('_all_', 'full')
|
| 275 |
+
if cached:
|
| 276 |
+
return cached
|
| 277 |
+
return {}
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def get_jst_date(dt: Optional[datetime] = None) -> str:
|
| 281 |
+
if dt is None:
|
| 282 |
+
dt = datetime.now()
|
| 283 |
+
|
| 284 |
+
jst = timezone(timedelta(hours=9))
|
| 285 |
+
jst_time = dt.astimezone(jst)
|
| 286 |
+
return jst_time.strftime('%Y-%m-%d')
|
| 287 |
+
|
| 288 |
+
def rewrite_m3u8(content: str, current_path: str, worker_base: str) -> str:
|
| 289 |
+
lines = content.split('\n')
|
| 290 |
+
output = []
|
| 291 |
+
|
| 292 |
+
if '?' in current_path:
|
| 293 |
+
base_path_part, query_part = current_path.rsplit('?', 1)
|
| 294 |
+
base_dir = base_path_part[:base_path_part.rfind('/') + 1]
|
| 295 |
+
else:
|
| 296 |
+
base_dir = current_path[:current_path.rfind('/') + 1]
|
| 297 |
+
query_part = ''
|
| 298 |
+
|
| 299 |
+
for line in lines:
|
| 300 |
+
trimmed = line.strip()
|
| 301 |
+
|
| 302 |
+
if trimmed.startswith('#') or not trimmed:
|
| 303 |
+
output.append(line)
|
| 304 |
+
continue
|
| 305 |
+
|
| 306 |
+
if trimmed.startswith('http://') or trimmed.startswith('https://'):
|
| 307 |
+
parsed = urlparse(trimmed)
|
| 308 |
+
target_path = parsed.path
|
| 309 |
+
if parsed.query:
|
| 310 |
+
target_path += f"?{parsed.query}"
|
| 311 |
+
|
| 312 |
+
elif trimmed.startswith('/'):
|
| 313 |
+
target_path = trimmed
|
| 314 |
+
|
| 315 |
+
else:
|
| 316 |
+
target_path = base_dir + trimmed
|
| 317 |
+
|
| 318 |
+
if '?' not in target_path and query_part:
|
| 319 |
+
target_path += f"?{query_part}"
|
| 320 |
+
|
| 321 |
+
output.append(worker_base + target_path)
|
| 322 |
+
|
| 323 |
+
return '\n'.join(output)
|
| 324 |
+
|
| 325 |
+
def extract_playlist_url(content: str, base_url: str) -> Optional[str]:
|
| 326 |
+
for line in content.split('\n'):
|
| 327 |
+
trimmed = line.strip()
|
| 328 |
+
|
| 329 |
+
if not trimmed or trimmed.startswith('#'):
|
| 330 |
+
continue
|
| 331 |
+
|
| 332 |
+
if trimmed.startswith('http'):
|
| 333 |
+
return trimmed
|
| 334 |
+
|
| 335 |
+
if trimmed.endswith('.m3u8') or trimmed.endswith('.M3U8'):
|
| 336 |
+
parsed = urlparse(base_url)
|
| 337 |
+
if trimmed.startswith('/'):
|
| 338 |
+
return f"{parsed.scheme}://{parsed.netloc}{trimmed}"
|
| 339 |
+
else:
|
| 340 |
+
base_path = base_url[:base_url.rfind('/') + 1]
|
| 341 |
+
return base_path + trimmed
|
| 342 |
+
|
| 343 |
+
return None
|