harii66 commited on
Commit
b4edbc0
·
verified ·
1 Parent(s): 602fb82

Upload 23 files

Browse files
Dockerfile CHANGED
@@ -1 +1,27 @@
1
- FROM tomoto6/gate:latest
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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