vflixa1prime commited on
New Feature added (#33)
Browse files- .gitattributes +2 -0
- Backend/__main__.py +6 -0
- Backend/fastapi/main.py +52 -3
- Backend/fastapi/routes/api_routes.py +212 -1
- Backend/fastapi/routes/stream_routes.py +94 -25
- Backend/fastapi/routes/stremio_routes.py +13 -0
- Backend/fastapi/routes/template_routes.py +15 -1
- Backend/fastapi/templates/admin_dashboard.html +342 -0
- Backend/fastapi/templates/base.html +91 -18
- Backend/fastapi/templates/dashboard.html +279 -76
- Backend/fastapi/templates/login.html +14 -14
- Backend/fastapi/templates/media_edit.html +687 -272
- Backend/fastapi/templates/media_management.html +317 -313
- Backend/fastapi/templates/public_status.html +8 -7
- Backend/helper/custom_dl.py +298 -17
- Backend/helper/database.py +214 -0
- Backend/helper/link_checker.py +117 -0
- Backend/pyrofork/bot.py +3 -1
- README.md +15 -4
- requirements.txt +1 -0
- update.py +2 -2
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Auto detect text files and perform LF normalization
|
| 2 |
+
* text=auto
|
Backend/__main__.py
CHANGED
|
@@ -10,6 +10,8 @@ from Backend.fastapi import server
|
|
| 10 |
from Backend.helper.pyro import restart_notification, setup_bot_commands
|
| 11 |
from Backend.pyrofork.bot import Helper, StreamBot
|
| 12 |
from Backend.pyrofork.clients import initialize_clients
|
|
|
|
|
|
|
| 13 |
|
| 14 |
loop = get_event_loop()
|
| 15 |
|
|
@@ -43,6 +45,10 @@ async def start_services():
|
|
| 43 |
loop.create_task(server.serve())
|
| 44 |
loop.create_task(ping())
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
LOGGER.info("Telegram-Stremio Started Successfully!")
|
| 47 |
await idle()
|
| 48 |
except Exception:
|
|
|
|
| 10 |
from Backend.helper.pyro import restart_notification, setup_bot_commands
|
| 11 |
from Backend.pyrofork.bot import Helper, StreamBot
|
| 12 |
from Backend.pyrofork.clients import initialize_clients
|
| 13 |
+
from Backend.helper.link_checker import DeadLinkChecker
|
| 14 |
+
from Backend.fastapi.main import app
|
| 15 |
|
| 16 |
loop = get_event_loop()
|
| 17 |
|
|
|
|
| 45 |
loop.create_task(server.serve())
|
| 46 |
loop.create_task(ping())
|
| 47 |
|
| 48 |
+
# Start the background Dead Link Checker
|
| 49 |
+
link_checker_task = DeadLinkChecker(db, app, check_interval_hours=24)
|
| 50 |
+
loop.create_task(link_checker_task.start())
|
| 51 |
+
|
| 52 |
LOGGER.info("Telegram-Stremio Started Successfully!")
|
| 53 |
await idle()
|
| 54 |
except Exception:
|
Backend/fastapi/main.py
CHANGED
|
@@ -5,17 +5,21 @@ from fastapi.staticfiles import StaticFiles
|
|
| 5 |
from starlette.middleware.sessions import SessionMiddleware
|
| 6 |
from Backend import __version__
|
| 7 |
from Backend.fastapi.security.credentials import require_auth
|
| 8 |
-
from Backend.fastapi.routes.stream_routes import router as stream_router
|
| 9 |
from Backend.fastapi.routes.stremio_routes import router as stremio_router
|
| 10 |
from Backend.fastapi.routes.template_routes import (
|
| 11 |
login_page, login_post, logout, set_theme, dashboard_page,
|
| 12 |
-
media_management_page, edit_media_page, public_status_page, stremio_guide_page
|
|
|
|
| 13 |
)
|
| 14 |
from Backend.fastapi.routes.api_routes import (
|
| 15 |
list_media_api, delete_media_api, update_media_api,
|
| 16 |
delete_movie_quality_api, delete_tv_quality_api,
|
| 17 |
delete_tv_episode_api, delete_tv_season_api,
|
| 18 |
-
create_token_api, revoke_token_api, update_token_limits_api
|
|
|
|
|
|
|
|
|
|
| 19 |
)
|
| 20 |
|
| 21 |
app = FastAPI(
|
|
@@ -39,6 +43,11 @@ try:
|
|
| 39 |
except Exception:
|
| 40 |
pass
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
# --- Include existing API routers ---
|
| 43 |
app.include_router(stream_router)
|
| 44 |
app.include_router(stremio_router)
|
|
@@ -73,6 +82,10 @@ async def stremio_guide(request: Request):
|
|
| 73 |
async def root(request: Request, _: bool = Depends(require_auth)):
|
| 74 |
return await dashboard_page(request, _)
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
@app.get("/media/manage", response_class=HTMLResponse)
|
| 77 |
async def media_management(request: Request, media_type: str = "movie", _: bool = Depends(require_auth)):
|
| 78 |
return await media_management_page(request, media_type, _)
|
|
@@ -147,6 +160,42 @@ async def get_system_stats(_: bool = Depends(require_auth)):
|
|
| 147 |
from Backend.fastapi.routes.api_routes import get_system_stats_api
|
| 148 |
return await get_system_stats_api()
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
@app.exception_handler(401)
|
| 151 |
async def auth_exception_handler(request: Request, exc):
|
| 152 |
return RedirectResponse(url="/login", status_code=302)
|
|
|
|
| 5 |
from starlette.middleware.sessions import SessionMiddleware
|
| 6 |
from Backend import __version__
|
| 7 |
from Backend.fastapi.security.credentials import require_auth
|
| 8 |
+
from Backend.fastapi.routes.stream_routes import router as stream_router, decay_client_failures
|
| 9 |
from Backend.fastapi.routes.stremio_routes import router as stremio_router
|
| 10 |
from Backend.fastapi.routes.template_routes import (
|
| 11 |
login_page, login_post, logout, set_theme, dashboard_page,
|
| 12 |
+
media_management_page, edit_media_page, public_status_page, stremio_guide_page,
|
| 13 |
+
admin_dashboard_page
|
| 14 |
)
|
| 15 |
from Backend.fastapi.routes.api_routes import (
|
| 16 |
list_media_api, delete_media_api, update_media_api,
|
| 17 |
delete_movie_quality_api, delete_tv_quality_api,
|
| 18 |
delete_tv_episode_api, delete_tv_season_api,
|
| 19 |
+
create_token_api, revoke_token_api, update_token_limits_api,
|
| 20 |
+
speed_test_api, speed_test_stream_api,
|
| 21 |
+
get_admin_stats_api, clear_cache_api, get_dead_links_api,
|
| 22 |
+
get_stream_analytics_api
|
| 23 |
)
|
| 24 |
|
| 25 |
app = FastAPI(
|
|
|
|
| 43 |
except Exception:
|
| 44 |
pass
|
| 45 |
|
| 46 |
+
@app.on_event("startup")
|
| 47 |
+
async def _startup():
|
| 48 |
+
import asyncio
|
| 49 |
+
asyncio.create_task(decay_client_failures())
|
| 50 |
+
|
| 51 |
# --- Include existing API routers ---
|
| 52 |
app.include_router(stream_router)
|
| 53 |
app.include_router(stremio_router)
|
|
|
|
| 82 |
async def root(request: Request, _: bool = Depends(require_auth)):
|
| 83 |
return await dashboard_page(request, _)
|
| 84 |
|
| 85 |
+
@app.get("/admin/dashboard", response_class=HTMLResponse)
|
| 86 |
+
async def admin_dashboard(request: Request, _: bool = Depends(require_auth)):
|
| 87 |
+
return await admin_dashboard_page(request, _)
|
| 88 |
+
|
| 89 |
@app.get("/media/manage", response_class=HTMLResponse)
|
| 90 |
async def media_management(request: Request, media_type: str = "movie", _: bool = Depends(require_auth)):
|
| 91 |
return await media_management_page(request, media_type, _)
|
|
|
|
| 160 |
from Backend.fastapi.routes.api_routes import get_system_stats_api
|
| 161 |
return await get_system_stats_api()
|
| 162 |
|
| 163 |
+
@app.get("/api/admin/system-stats")
|
| 164 |
+
async def admin_system_stats(_: bool = Depends(require_auth)):
|
| 165 |
+
return await get_admin_stats_api()
|
| 166 |
+
|
| 167 |
+
@app.post("/api/admin/clear-cache")
|
| 168 |
+
async def clear_cache(_: bool = Depends(require_auth)):
|
| 169 |
+
return await clear_cache_api()
|
| 170 |
+
|
| 171 |
+
@app.get("/api/admin/dead-links")
|
| 172 |
+
async def get_dead_links(_: bool = Depends(require_auth)):
|
| 173 |
+
return await get_dead_links_api()
|
| 174 |
+
|
| 175 |
+
@app.get("/api/admin/stream-analytics")
|
| 176 |
+
async def get_stream_analytics(_: bool = Depends(require_auth)):
|
| 177 |
+
return await get_stream_analytics_api()
|
| 178 |
+
|
| 179 |
+
@app.get("/api/system/speedtest")
|
| 180 |
+
async def speed_test(
|
| 181 |
+
quality_id: str = Query(...),
|
| 182 |
+
tmdb_id: int = Query(...),
|
| 183 |
+
db_index: int = Query(...),
|
| 184 |
+
media_type: str = Query(...),
|
| 185 |
+
_: bool = Depends(require_auth)
|
| 186 |
+
):
|
| 187 |
+
return await speed_test_api(quality_id, tmdb_id, db_index, media_type)
|
| 188 |
+
|
| 189 |
+
@app.get("/api/system/speedtest/stream")
|
| 190 |
+
async def speed_test_stream(
|
| 191 |
+
quality_id: str = Query(...),
|
| 192 |
+
tmdb_id: int = Query(...),
|
| 193 |
+
db_index: int = Query(...),
|
| 194 |
+
media_type: str = Query(...),
|
| 195 |
+
_: bool = Depends(require_auth)
|
| 196 |
+
):
|
| 197 |
+
return await speed_test_stream_api(quality_id, tmdb_id, db_index, media_type)
|
| 198 |
+
|
| 199 |
@app.exception_handler(401)
|
| 200 |
async def auth_exception_handler(request: Request, exc):
|
| 201 |
return RedirectResponse(url="/login", status_code=302)
|
Backend/fastapi/routes/api_routes.py
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
|
|
|
|
|
| 1 |
from fastapi import Request, Query, HTTPException
|
|
|
|
| 2 |
from Backend import db, StartTime, __version__
|
| 3 |
from Backend.helper.pyro import get_readable_time
|
| 4 |
from Backend.pyrofork.bot import multi_clients, StreamBot
|
|
|
|
| 5 |
from time import time
|
| 6 |
|
| 7 |
|
|
@@ -257,4 +261,211 @@ async def revoke_token_api(token: str):
|
|
| 257 |
else:
|
| 258 |
raise HTTPException(status_code=404, detail="Token not found")
|
| 259 |
except Exception as e:
|
| 260 |
-
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
from fastapi import Request, Query, HTTPException
|
| 4 |
+
from fastapi.responses import StreamingResponse
|
| 5 |
from Backend import db, StartTime, __version__
|
| 6 |
from Backend.helper.pyro import get_readable_time
|
| 7 |
from Backend.pyrofork.bot import multi_clients, StreamBot
|
| 8 |
+
from Backend.helper.custom_dl import run_speed_test, _speed_test_single_client
|
| 9 |
from time import time
|
| 10 |
|
| 11 |
|
|
|
|
| 261 |
else:
|
| 262 |
raise HTTPException(status_code=404, detail="Token not found")
|
| 263 |
except Exception as e:
|
| 264 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
# --- Speed Test API ---
|
| 268 |
+
|
| 269 |
+
async def speed_test_api(
|
| 270 |
+
quality_id: str = Query(..., description="Encoded quality ID from DB"),
|
| 271 |
+
tmdb_id: int = Query(...),
|
| 272 |
+
db_index: int = Query(...),
|
| 273 |
+
media_type: str = Query(..., regex="^(movie|tv)$"),
|
| 274 |
+
):
|
| 275 |
+
"""
|
| 276 |
+
Decode quality_id using the same decode_string logic as the stream handler,
|
| 277 |
+
then run a parallel download speed test across all connected bot clients.
|
| 278 |
+
"""
|
| 279 |
+
from Backend.helper.encrypt import decode_string
|
| 280 |
+
|
| 281 |
+
try:
|
| 282 |
+
decoded = await decode_string(quality_id)
|
| 283 |
+
msg_id = decoded.get("msg_id")
|
| 284 |
+
raw_cid = decoded.get("chat_id")
|
| 285 |
+
|
| 286 |
+
if not msg_id or not raw_cid:
|
| 287 |
+
raise HTTPException(
|
| 288 |
+
status_code=422,
|
| 289 |
+
detail=f"Decoded quality data is missing msg_id or chat_id. Decoded: {decoded}"
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
# Stream handler adds -100 prefix for channel IDs
|
| 293 |
+
chat_id = int(f"-100{raw_cid}")
|
| 294 |
+
|
| 295 |
+
results = await run_speed_test(int(chat_id), int(msg_id))
|
| 296 |
+
return {"results": results, "total_clients_tested": len(results)}
|
| 297 |
+
|
| 298 |
+
except HTTPException:
|
| 299 |
+
raise
|
| 300 |
+
except Exception as e:
|
| 301 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
# --- Speed Test SSE Streaming API ---
|
| 305 |
+
|
| 306 |
+
async def speed_test_stream_api(
|
| 307 |
+
quality_id: str,
|
| 308 |
+
tmdb_id: int,
|
| 309 |
+
db_index: int,
|
| 310 |
+
media_type: str,
|
| 311 |
+
):
|
| 312 |
+
"""
|
| 313 |
+
SSE version of the speed test. Streams each per-client result as a
|
| 314 |
+
'data:' event the moment that client finishes, so the UI can update live.
|
| 315 |
+
"""
|
| 316 |
+
from Backend.helper.encrypt import decode_string
|
| 317 |
+
|
| 318 |
+
async def event_generator():
|
| 319 |
+
# Decode quality_id β chat_id + message_id
|
| 320 |
+
try:
|
| 321 |
+
decoded = await decode_string(quality_id)
|
| 322 |
+
msg_id = decoded.get("msg_id")
|
| 323 |
+
raw_cid = decoded.get("chat_id")
|
| 324 |
+
if not msg_id or not raw_cid:
|
| 325 |
+
payload = json.dumps({"type": "error", "message": f"Cannot decode quality_id. Got: {decoded}"})
|
| 326 |
+
yield f"data: {payload}\n\n"
|
| 327 |
+
return
|
| 328 |
+
chat_id = int(f"-100{raw_cid}")
|
| 329 |
+
except Exception as exc:
|
| 330 |
+
payload = json.dumps({"type": "error", "message": str(exc)})
|
| 331 |
+
yield f"data: {payload}\n\n"
|
| 332 |
+
return
|
| 333 |
+
|
| 334 |
+
total = len(multi_clients)
|
| 335 |
+
if total == 0:
|
| 336 |
+
payload = json.dumps({"type": "error", "message": "No bot clients connected"})
|
| 337 |
+
yield f"data: {payload}\n\n"
|
| 338 |
+
return
|
| 339 |
+
|
| 340 |
+
# Try to resolve the FileId to get the target DC
|
| 341 |
+
target_dc = "?"
|
| 342 |
+
try:
|
| 343 |
+
from Backend.helper.custom_dl import ByteStreamer
|
| 344 |
+
primary_client = multi_clients.get(0) or next(iter(multi_clients.values()))
|
| 345 |
+
streamer = ByteStreamer(primary_client)
|
| 346 |
+
file_id = await streamer.get_file_properties(chat_id, int(msg_id))
|
| 347 |
+
target_dc = file_id.dc_id
|
| 348 |
+
except Exception:
|
| 349 |
+
pass
|
| 350 |
+
|
| 351 |
+
# Send initial "start" event so the frontend can set up the table
|
| 352 |
+
yield f"data: {json.dumps({'type': 'start', 'total': total, 'target_dc': target_dc})}\n\n"
|
| 353 |
+
|
| 354 |
+
# Run all clients in parallel; feed results into a queue as they finish
|
| 355 |
+
queue: asyncio.Queue = asyncio.Queue()
|
| 356 |
+
|
| 357 |
+
async def run_one(client, idx):
|
| 358 |
+
async def on_progress(prog_data):
|
| 359 |
+
await queue.put({"type": "progress", "data": prog_data})
|
| 360 |
+
|
| 361 |
+
result = await _speed_test_single_client(
|
| 362 |
+
client, idx, chat_id, int(msg_id), progress_callback=on_progress
|
| 363 |
+
)
|
| 364 |
+
await queue.put({"type": "result", "data": result})
|
| 365 |
+
|
| 366 |
+
tasks = [
|
| 367 |
+
asyncio.create_task(run_one(client, idx))
|
| 368 |
+
for idx, client in multi_clients.items()
|
| 369 |
+
]
|
| 370 |
+
|
| 371 |
+
completed = 0
|
| 372 |
+
while completed < total:
|
| 373 |
+
msg = await queue.get()
|
| 374 |
+
|
| 375 |
+
if msg["type"] == "progress":
|
| 376 |
+
payload = json.dumps(msg)
|
| 377 |
+
yield f"data: {payload}\n\n"
|
| 378 |
+
|
| 379 |
+
elif msg["type"] == "result":
|
| 380 |
+
completed += 1
|
| 381 |
+
payload = json.dumps({
|
| 382 |
+
"type": "result",
|
| 383 |
+
"data": msg["data"],
|
| 384 |
+
"completed": completed,
|
| 385 |
+
"total": total,
|
| 386 |
+
})
|
| 387 |
+
yield f"data: {payload}\n\n"
|
| 388 |
+
|
| 389 |
+
# Wait for any remaining tasks (should already be done)
|
| 390 |
+
await asyncio.gather(*tasks, return_exceptions=True)
|
| 391 |
+
|
| 392 |
+
# Final done event
|
| 393 |
+
yield f"data: {json.dumps({'type': 'done', 'total': total})}\n\n"
|
| 394 |
+
|
| 395 |
+
return StreamingResponse(
|
| 396 |
+
event_generator(),
|
| 397 |
+
media_type="text/event-stream",
|
| 398 |
+
headers={
|
| 399 |
+
"Cache-Control": "no-cache",
|
| 400 |
+
"X-Accel-Buffering": "no", # prevent nginx from buffering SSE
|
| 401 |
+
},
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
# ---------------------------------------------------------------------------
|
| 405 |
+
# Admin API Routes
|
| 406 |
+
# ---------------------------------------------------------------------------
|
| 407 |
+
|
| 408 |
+
async def get_admin_stats_api() -> dict:
|
| 409 |
+
from Backend.pyrofork.bot import work_loads, multi_clients, client_failures, client_avg_mbps
|
| 410 |
+
from Backend.fastapi.routes.stream_routes import _streamer_by_client
|
| 411 |
+
|
| 412 |
+
# Sum cache entries across all active ByteStreamer instances
|
| 413 |
+
cache_size = sum(len(s._file_id_cache) for s in _streamer_by_client.values())
|
| 414 |
+
|
| 415 |
+
# Calculate bot workloads and health
|
| 416 |
+
bot_stats = []
|
| 417 |
+
for client_index in multi_clients:
|
| 418 |
+
load = work_loads.get(client_index, 0)
|
| 419 |
+
failures = client_failures.get(client_index, 0)
|
| 420 |
+
mbps = client_avg_mbps.get(client_index, 0.0)
|
| 421 |
+
|
| 422 |
+
status = "healthy"
|
| 423 |
+
if failures > 5:
|
| 424 |
+
status = "degraded"
|
| 425 |
+
if failures > 15:
|
| 426 |
+
status = "failing"
|
| 427 |
+
|
| 428 |
+
bot_stats.append({
|
| 429 |
+
"client_index": client_index,
|
| 430 |
+
"display_name": f"Bot {client_index + 1}",
|
| 431 |
+
"current_load": load,
|
| 432 |
+
"failures": failures,
|
| 433 |
+
"avg_mbps": round(mbps, 2),
|
| 434 |
+
"status": status
|
| 435 |
+
})
|
| 436 |
+
|
| 437 |
+
return {
|
| 438 |
+
"cache_size": cache_size,
|
| 439 |
+
"total_bots": len(multi_clients),
|
| 440 |
+
"bot_workloads": bot_stats
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
async def clear_cache_api() -> dict:
|
| 444 |
+
from Backend.fastapi.routes.stream_routes import _streamer_by_client
|
| 445 |
+
from Backend.logger import LOGGER
|
| 446 |
+
|
| 447 |
+
# Clear cache across all active ByteStreamer instances
|
| 448 |
+
total_cleared = sum(len(s._file_id_cache) for s in _streamer_by_client.values())
|
| 449 |
+
for streamer in _streamer_by_client.values():
|
| 450 |
+
streamer._file_id_cache.clear()
|
| 451 |
+
LOGGER.info(f"Admin cleared the FileId cache ({total_cleared} items purged across {len(_streamer_by_client)} clients).")
|
| 452 |
+
|
| 453 |
+
return {"status": "success", "message": f"{total_cleared} cached items cleared."}
|
| 454 |
+
|
| 455 |
+
async def get_dead_links_api() -> dict:
|
| 456 |
+
from Backend import db
|
| 457 |
+
try:
|
| 458 |
+
dead_links = await db.get_all_dead_links()
|
| 459 |
+
return {"status": "success", "data": dead_links}
|
| 460 |
+
except Exception as e:
|
| 461 |
+
return {"status": "error", "message": str(e)}
|
| 462 |
+
|
| 463 |
+
async def get_stream_analytics_api() -> dict:
|
| 464 |
+
from Backend import db
|
| 465 |
+
try:
|
| 466 |
+
data = await db.get_stream_analytics(limit=200)
|
| 467 |
+
return {"status": "success", "data": data}
|
| 468 |
+
except Exception as e:
|
| 469 |
+
from Backend.logger import LOGGER
|
| 470 |
+
LOGGER.error(f"Stream analytics API error: {e}")
|
| 471 |
+
return {"status": "error", "message": str(e)}
|
Backend/fastapi/routes/stream_routes.py
CHANGED
|
@@ -12,8 +12,8 @@ from collections import deque
|
|
| 12 |
from Backend import db
|
| 13 |
from Backend.helper.encrypt import decode_string
|
| 14 |
from Backend.helper.exceptions import InvalidHash
|
| 15 |
-
from Backend.helper.custom_dl import ByteStreamer, ACTIVE_STREAMS, RECENT_STREAMS
|
| 16 |
-
from Backend.pyrofork.bot import StreamBot, work_loads, multi_clients, client_dc_map
|
| 17 |
from Backend.config import Telegram
|
| 18 |
from Backend.logger import LOGGER
|
| 19 |
from Backend.fastapi.security.tokens import verify_token
|
|
@@ -65,33 +65,55 @@ def parse_range_header(range_header: str, file_size: int):
|
|
| 65 |
|
| 66 |
|
| 67 |
def select_best_client(target_dc: int) -> int:
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
# return selected
|
| 81 |
-
#
|
| 82 |
|
| 83 |
if multi_clients:
|
| 84 |
-
selected = min(
|
| 85 |
-
selected_dc = client_dc_map.get(selected, "unknown")
|
| 86 |
LOGGER.debug(
|
| 87 |
-
|
| 88 |
-
|
| 89 |
)
|
| 90 |
return selected
|
| 91 |
|
| 92 |
return 0
|
| 93 |
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
async def track_usage_from_stats(stream_id: str, token: str, token_data: dict):
|
| 96 |
await asyncio.sleep(2)
|
| 97 |
|
|
@@ -186,6 +208,7 @@ async def stream_handler(
|
|
| 186 |
secure_hash=secure_hash,
|
| 187 |
token=token,
|
| 188 |
token_data=token_data,
|
|
|
|
| 189 |
)
|
| 190 |
|
| 191 |
async def media_streamer(
|
|
@@ -195,10 +218,12 @@ async def media_streamer(
|
|
| 195 |
secure_hash: str,
|
| 196 |
token: str,
|
| 197 |
token_data: dict = None,
|
|
|
|
| 198 |
):
|
| 199 |
-
temp_client = multi_clients[min(
|
| 200 |
if temp_client not in _streamer_by_client:
|
| 201 |
-
|
|
|
|
| 202 |
temp_streamer = _streamer_by_client[temp_client]
|
| 203 |
|
| 204 |
file_id = await temp_streamer.get_file_properties(chat_id=chat_id, message_id=msg_id)
|
|
@@ -214,7 +239,7 @@ async def media_streamer(
|
|
| 214 |
tg_client = multi_clients[index]
|
| 215 |
|
| 216 |
if tg_client not in _streamer_by_client:
|
| 217 |
-
_streamer_by_client[tg_client] = ByteStreamer(tg_client)
|
| 218 |
streamer: ByteStreamer = _streamer_by_client[tg_client]
|
| 219 |
|
| 220 |
file_size = file_id.file_size
|
|
@@ -222,16 +247,33 @@ async def media_streamer(
|
|
| 222 |
start, end = parse_range_header(range_header, file_size)
|
| 223 |
req_length = end - start + 1
|
| 224 |
|
| 225 |
-
|
|
|
|
| 226 |
offset = start - (start % chunk_size)
|
| 227 |
first_part_cut = start - offset
|
| 228 |
last_part_cut = (end % chunk_size) + 1
|
| 229 |
part_count = math.ceil(end / chunk_size) - math.floor(offset / chunk_size)
|
| 230 |
|
|
|
|
|
|
|
| 231 |
stream_id = secrets.token_hex(8)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
meta = {
|
| 233 |
"request_path": str(request.url.path),
|
| 234 |
"client_host": request.client.host if request.client else None,
|
|
|
|
|
|
|
| 235 |
}
|
| 236 |
|
| 237 |
prefetch_count = Telegram.PARALLEL
|
|
@@ -260,9 +302,33 @@ async def media_streamer(
|
|
| 260 |
if "." not in file_name and "/" in mime_type:
|
| 261 |
file_name = f"{file_name}.{mime_type.split('/')[1]}"
|
| 262 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
headers = {
|
| 264 |
"Content-Type": mime_type,
|
| 265 |
-
"Content-Length": str(req_length),
|
| 266 |
"Content-Disposition": f'inline; filename="{file_name}"',
|
| 267 |
"Accept-Ranges": "bytes",
|
| 268 |
"Cache-Control": "public, max-age=3600, immutable",
|
|
@@ -290,7 +356,8 @@ async def get_stream_stats():
|
|
| 290 |
|
| 291 |
for sid, info in list(ACTIVE_STREAMS.items()):
|
| 292 |
status = info.get("status")
|
| 293 |
-
|
|
|
|
| 294 |
if status in ("cancelled", "error", "finished"):
|
| 295 |
if now - last_ts > PRUNE_SECONDS:
|
| 296 |
try:
|
|
@@ -305,6 +372,7 @@ async def get_stream_stats():
|
|
| 305 |
"stream_id": sid,
|
| 306 |
"msg_id": info.get("msg_id"),
|
| 307 |
"chat_id": info.get("chat_id"),
|
|
|
|
| 308 |
"client_index": info.get("client_index"),
|
| 309 |
"dc_id": info.get("dc_id"),
|
| 310 |
"status": info.get("status"),
|
|
@@ -323,6 +391,7 @@ async def get_stream_stats():
|
|
| 323 |
"stream_id": info.get("stream_id"),
|
| 324 |
"msg_id": info.get("msg_id"),
|
| 325 |
"chat_id": info.get("chat_id"),
|
|
|
|
| 326 |
"client_index": info.get("client_index"),
|
| 327 |
"dc_id": info.get("dc_id"),
|
| 328 |
"status": info.get("status"),
|
|
|
|
| 12 |
from Backend import db
|
| 13 |
from Backend.helper.encrypt import decode_string
|
| 14 |
from Backend.helper.exceptions import InvalidHash
|
| 15 |
+
from Backend.helper.custom_dl import ByteStreamer, ACTIVE_STREAMS, RECENT_STREAMS, get_adaptive_chunk_size
|
| 16 |
+
from Backend.pyrofork.bot import StreamBot, work_loads, multi_clients, client_dc_map, client_failures, client_avg_mbps
|
| 17 |
from Backend.config import Telegram
|
| 18 |
from Backend.logger import LOGGER
|
| 19 |
from Backend.fastapi.security.tokens import verify_token
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
def select_best_client(target_dc: int) -> int:
|
| 68 |
+
"""Pick the best available client.
|
| 69 |
+
|
| 70 |
+
Score = work_loads + 3 Γ client_failures
|
| 71 |
+
Failures are weighted 3Γ so a bot that has been timing out / erroring
|
| 72 |
+
is deprioritised even if its current workload is low.
|
| 73 |
+
DC-aware selection is kept but currently commented out (uncomment to
|
| 74 |
+
prefer same-DC bots).
|
| 75 |
+
"""
|
| 76 |
+
def _score(idx: int) -> int:
|
| 77 |
+
return work_loads.get(idx, 0) + 3 * client_failures.get(idx, 0)
|
| 78 |
+
|
| 79 |
+
# --- DC-aware selection (uncomment to enable) ---------------------------
|
| 80 |
+
# matching = [
|
| 81 |
+
# idx for idx, dc in client_dc_map.items()
|
| 82 |
+
# if dc == target_dc and idx in multi_clients
|
| 83 |
+
# ]
|
| 84 |
+
# if matching:
|
| 85 |
+
# selected = min(matching, key=_score)
|
| 86 |
+
# LOGGER.debug("DC-match client %s (DC %s) score=%s", selected, target_dc, _score(selected))
|
| 87 |
# return selected
|
| 88 |
+
# ------------------------------------------------------------------------
|
| 89 |
|
| 90 |
if multi_clients:
|
| 91 |
+
selected = min(multi_clients.keys(), key=_score)
|
|
|
|
| 92 |
LOGGER.debug(
|
| 93 |
+
"Selected client %s (DC %s) score=%s",
|
| 94 |
+
selected, client_dc_map.get(selected, "?"), _score(selected),
|
| 95 |
)
|
| 96 |
return selected
|
| 97 |
|
| 98 |
return 0
|
| 99 |
|
| 100 |
|
| 101 |
+
async def decay_client_failures() -> None:
|
| 102 |
+
"""Every 5 minutes reduce each client's failure count by 1 (floor 0).
|
| 103 |
+
|
| 104 |
+
This lets bots self-recover after a temporary DC issue without manual
|
| 105 |
+
intervention. The coroutine is started once as a background task on
|
| 106 |
+
first import.
|
| 107 |
+
"""
|
| 108 |
+
while True:
|
| 109 |
+
await asyncio.sleep(300) # 5 minutes
|
| 110 |
+
for k in list(client_failures):
|
| 111 |
+
if client_failures.get(k, 0) > 0:
|
| 112 |
+
client_failures[k] = max(0, client_failures[k] - 1)
|
| 113 |
+
LOGGER.debug("Failure decay: client %s failures β %s", k, client_failures[k])
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
|
| 117 |
async def track_usage_from_stats(stream_id: str, token: str, token_data: dict):
|
| 118 |
await asyncio.sleep(2)
|
| 119 |
|
|
|
|
| 208 |
secure_hash=secure_hash,
|
| 209 |
token=token,
|
| 210 |
token_data=token_data,
|
| 211 |
+
stream_id_hash=id,
|
| 212 |
)
|
| 213 |
|
| 214 |
async def media_streamer(
|
|
|
|
| 218 |
secure_hash: str,
|
| 219 |
token: str,
|
| 220 |
token_data: dict = None,
|
| 221 |
+
stream_id_hash: str = None,
|
| 222 |
):
|
| 223 |
+
temp_client = multi_clients[min(multi_clients.keys(), key=lambda i: work_loads.get(i, 0) + 3 * client_failures.get(i, 0))]
|
| 224 |
if temp_client not in _streamer_by_client:
|
| 225 |
+
idx = next((i for i, c in multi_clients.items() if c is temp_client), -1)
|
| 226 |
+
_streamer_by_client[temp_client] = ByteStreamer(temp_client, idx)
|
| 227 |
temp_streamer = _streamer_by_client[temp_client]
|
| 228 |
|
| 229 |
file_id = await temp_streamer.get_file_properties(chat_id=chat_id, message_id=msg_id)
|
|
|
|
| 239 |
tg_client = multi_clients[index]
|
| 240 |
|
| 241 |
if tg_client not in _streamer_by_client:
|
| 242 |
+
_streamer_by_client[tg_client] = ByteStreamer(tg_client, index)
|
| 243 |
streamer: ByteStreamer = _streamer_by_client[tg_client]
|
| 244 |
|
| 245 |
file_size = file_id.file_size
|
|
|
|
| 247 |
start, end = parse_range_header(range_header, file_size)
|
| 248 |
req_length = end - start + 1
|
| 249 |
|
| 250 |
+
# Adaptive chunk size based on this client's recent measured throughput
|
| 251 |
+
chunk_size = get_adaptive_chunk_size(index)
|
| 252 |
offset = start - (start % chunk_size)
|
| 253 |
first_part_cut = start - offset
|
| 254 |
last_part_cut = (end % chunk_size) + 1
|
| 255 |
part_count = math.ceil(end / chunk_size) - math.floor(offset / chunk_size)
|
| 256 |
|
| 257 |
+
from urllib.parse import unquote
|
| 258 |
+
|
| 259 |
stream_id = secrets.token_hex(8)
|
| 260 |
+
|
| 261 |
+
# Extract original title from the URL path name, fallback to raw name
|
| 262 |
+
decoded_name = unquote(request.path_params.get("name", ""))
|
| 263 |
+
|
| 264 |
+
# Look up the real title from the database using the Stremio stream_id_hash
|
| 265 |
+
db_title = None
|
| 266 |
+
if stream_id_hash:
|
| 267 |
+
db_title = await db.get_title_by_stream_id(stream_id_hash)
|
| 268 |
+
LOGGER.info(f"Stream lookup for hash '{stream_id_hash}' returned title: {db_title}")
|
| 269 |
+
|
| 270 |
+
final_title = db_title if db_title else decoded_name
|
| 271 |
+
|
| 272 |
meta = {
|
| 273 |
"request_path": str(request.url.path),
|
| 274 |
"client_host": request.client.host if request.client else None,
|
| 275 |
+
"title": final_title,
|
| 276 |
+
"user_name": token_data.get("name", "Unknown") if token_data else "Unknown"
|
| 277 |
}
|
| 278 |
|
| 279 |
prefetch_count = Telegram.PARALLEL
|
|
|
|
| 302 |
if "." not in file_name and "/" in mime_type:
|
| 303 |
file_name = f"{file_name}.{mime_type.split('/')[1]}"
|
| 304 |
|
| 305 |
+
# HEAD: return headers only (no body), include Content-Length so the
|
| 306 |
+
# client knows the file size without opening a stream.
|
| 307 |
+
from fastapi.responses import Response as PlainResponse
|
| 308 |
+
if request.method == "HEAD":
|
| 309 |
+
head_headers = {
|
| 310 |
+
"Content-Type": mime_type,
|
| 311 |
+
"Content-Length": str(req_length),
|
| 312 |
+
"Content-Disposition": f'inline; filename="{file_name}"',
|
| 313 |
+
"Accept-Ranges": "bytes",
|
| 314 |
+
"Cache-Control": "public, max-age=3600, immutable",
|
| 315 |
+
"Access-Control-Allow-Origin": "*",
|
| 316 |
+
"Access-Control-Expose-Headers": "Content-Length, Content-Range, Accept-Ranges",
|
| 317 |
+
"X-Stream-Id": stream_id,
|
| 318 |
+
}
|
| 319 |
+
if range_header:
|
| 320 |
+
head_headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
| 321 |
+
return PlainResponse(status_code=206 if range_header else 200, headers=head_headers)
|
| 322 |
+
|
| 323 |
+
# GET: do NOT set Content-Length on the StreamingResponse.
|
| 324 |
+
# If a Telegram chunk fetch times out mid-stream the generator exits early,
|
| 325 |
+
# delivering fewer bytes than the declared length. h11 enforces
|
| 326 |
+
# Content-Length strictly and raises LocalProtocolError in that case.
|
| 327 |
+
# Without Content-Length, uvicorn uses chunked transfer encoding which
|
| 328 |
+
# handles early termination gracefully. Stremio / media players
|
| 329 |
+
# are fine with chunked 206 responses.
|
| 330 |
headers = {
|
| 331 |
"Content-Type": mime_type,
|
|
|
|
| 332 |
"Content-Disposition": f'inline; filename="{file_name}"',
|
| 333 |
"Accept-Ranges": "bytes",
|
| 334 |
"Cache-Control": "public, max-age=3600, immutable",
|
|
|
|
| 356 |
|
| 357 |
for sid, info in list(ACTIVE_STREAMS.items()):
|
| 358 |
status = info.get("status")
|
| 359 |
+
# Check end_ts first, which is set when a stream organically finishes
|
| 360 |
+
last_ts = info.get("end_ts") or info.get("last_ts") or info.get("start_ts", now)
|
| 361 |
if status in ("cancelled", "error", "finished"):
|
| 362 |
if now - last_ts > PRUNE_SECONDS:
|
| 363 |
try:
|
|
|
|
| 372 |
"stream_id": sid,
|
| 373 |
"msg_id": info.get("msg_id"),
|
| 374 |
"chat_id": info.get("chat_id"),
|
| 375 |
+
"title": info.get("meta", {}).get("title"),
|
| 376 |
"client_index": info.get("client_index"),
|
| 377 |
"dc_id": info.get("dc_id"),
|
| 378 |
"status": info.get("status"),
|
|
|
|
| 391 |
"stream_id": info.get("stream_id"),
|
| 392 |
"msg_id": info.get("msg_id"),
|
| 393 |
"chat_id": info.get("chat_id"),
|
| 394 |
+
"title": info.get("meta", {}).get("title"),
|
| 395 |
"client_index": info.get("client_index"),
|
| 396 |
"dc_id": info.get("dc_id"),
|
| 397 |
"status": info.get("status"),
|
Backend/fastapi/routes/stremio_routes.py
CHANGED
|
@@ -361,4 +361,17 @@ async def get_streams(
|
|
| 361 |
key=lambda s: get_resolution_priority(s.get("name", "")),
|
| 362 |
reverse=True
|
| 363 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
return {"streams": streams}
|
|
|
|
| 361 |
key=lambda s: get_resolution_priority(s.get("name", "")),
|
| 362 |
reverse=True
|
| 363 |
)
|
| 364 |
+
|
| 365 |
+
# Deduplicate stream names β Stremio collapses streams with identical names,
|
| 366 |
+
# so when two files share the same caption we append (1), (2) ... to each duplicate.
|
| 367 |
+
name_count: dict = {}
|
| 368 |
+
for s in streams:
|
| 369 |
+
name_count[s["name"]] = name_count.get(s["name"], 0) + 1
|
| 370 |
+
|
| 371 |
+
seen: dict = {}
|
| 372 |
+
for s in streams:
|
| 373 |
+
if name_count[s["name"]] > 1:
|
| 374 |
+
seen[s["name"]] = seen.get(s["name"], 0) + 1
|
| 375 |
+
s["name"] = f"{s['name']} ({seen[s['name']]})"
|
| 376 |
+
|
| 377 |
return {"streams": streams}
|
Backend/fastapi/routes/template_routes.py
CHANGED
|
@@ -12,6 +12,19 @@ from Backend.helper.custom_dl import ACTIVE_STREAMS, RECENT_STREAMS
|
|
| 12 |
|
| 13 |
templates = Jinja2Templates(directory="Backend/fastapi/templates")
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
async def login_page(request: Request):
|
| 16 |
if is_authenticated(request):
|
| 17 |
return RedirectResponse(url="/", status_code=302)
|
|
@@ -65,7 +78,8 @@ async def dashboard_page(request: Request, _: bool = Depends(require_auth)):
|
|
| 65 |
PRUNE_SECONDS = 3
|
| 66 |
for sid, info in list(ACTIVE_STREAMS.items()):
|
| 67 |
status = info.get("status")
|
| 68 |
-
|
|
|
|
| 69 |
|
| 70 |
if status in ("cancelled", "error", "finished") and (now - last_ts > PRUNE_SECONDS):
|
| 71 |
|
|
|
|
| 12 |
|
| 13 |
templates = Jinja2Templates(directory="Backend/fastapi/templates")
|
| 14 |
|
| 15 |
+
async def admin_dashboard_page(request: Request, _: bool = Depends(require_auth)):
|
| 16 |
+
theme_name = request.session.get("theme", "purple_gradient")
|
| 17 |
+
theme = get_theme(theme_name)
|
| 18 |
+
current_user = get_current_user(request)
|
| 19 |
+
|
| 20 |
+
return templates.TemplateResponse("admin_dashboard.html", {
|
| 21 |
+
"request": request,
|
| 22 |
+
"theme": theme,
|
| 23 |
+
"themes": get_all_themes(),
|
| 24 |
+
"current_theme": theme_name,
|
| 25 |
+
"current_user": current_user
|
| 26 |
+
})
|
| 27 |
+
|
| 28 |
async def login_page(request: Request):
|
| 29 |
if is_authenticated(request):
|
| 30 |
return RedirectResponse(url="/", status_code=302)
|
|
|
|
| 78 |
PRUNE_SECONDS = 3
|
| 79 |
for sid, info in list(ACTIVE_STREAMS.items()):
|
| 80 |
status = info.get("status")
|
| 81 |
+
# Check end_ts first to see when it organically finished
|
| 82 |
+
last_ts = info.get("end_ts") or info.get("last_ts") or info.get("start_ts", now)
|
| 83 |
|
| 84 |
if status in ("cancelled", "error", "finished") and (now - last_ts > PRUNE_SECONDS):
|
| 85 |
|
Backend/fastapi/templates/admin_dashboard.html
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Admin Dashboard - Telegram Stremio{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 7 |
+
<div class="mb-8 flex justify-between items-center">
|
| 8 |
+
<div>
|
| 9 |
+
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Admin Dashboard</h1>
|
| 10 |
+
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Monitor system health and manage flagged dead
|
| 11 |
+
links.</p>
|
| 12 |
+
</div>
|
| 13 |
+
</div>
|
| 14 |
+
|
| 15 |
+
<!-- System Health Section -->
|
| 16 |
+
<div class="mb-10">
|
| 17 |
+
<div class="flex items-center justify-between mb-4">
|
| 18 |
+
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">System Health</h2>
|
| 19 |
+
<button onclick="clearCache()"
|
| 20 |
+
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors shadow-sm flex items-center justify-center">
|
| 21 |
+
<i class="fas fa-trash-alt mr-2"></i> Clear File Cache
|
| 22 |
+
</button>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-6" id="stats-container">
|
| 26 |
+
<!-- Cache Card -->
|
| 27 |
+
<div class="theme-card rounded-xl border p-6 shadow-sm">
|
| 28 |
+
<div class="flex items-center">
|
| 29 |
+
<div class="flex-shrink-0 bg-blue-100 dark:bg-blue-900/30 p-3 rounded-lg">
|
| 30 |
+
<i class="fas fa-memory text-xl text-blue-600 dark:text-blue-400"></i>
|
| 31 |
+
</div>
|
| 32 |
+
<div class="ml-4">
|
| 33 |
+
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Cached File IDs</h3>
|
| 34 |
+
<p class="text-2xl font-bold text-gray-900 dark:text-white" id="stat-cache-size">-</p>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
<!-- Active Bots Card -->
|
| 40 |
+
<div class="theme-card rounded-xl border p-6 shadow-sm">
|
| 41 |
+
<div class="flex items-center">
|
| 42 |
+
<div class="flex-shrink-0 bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
|
| 43 |
+
<i class="fas fa-robot text-xl text-green-600 dark:text-green-400"></i>
|
| 44 |
+
</div>
|
| 45 |
+
<div class="ml-4">
|
| 46 |
+
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Connected Bots</h3>
|
| 47 |
+
<p class="text-2xl font-bold text-gray-900 dark:text-white" id="stat-bot-count">-</p>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<!-- Bot Health & Load - Full Width -->
|
| 55 |
+
<div class="theme-card rounded-xl border shadow-sm mt-6">
|
| 56 |
+
<div class="flex items-center justify-between px-6 py-4 border-b">
|
| 57 |
+
<div class="flex items-center gap-3">
|
| 58 |
+
<div class="bg-purple-100 dark:bg-purple-900/30 p-2 rounded-lg">
|
| 59 |
+
<i class="fas fa-heartbeat text-purple-600 dark:text-purple-400"></i>
|
| 60 |
+
</div>
|
| 61 |
+
<h3 class="text-base font-semibold text-gray-800 dark:text-gray-200">Bot Health & Load</h3>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
<div id="stat-workloads" class="divide-y divide-gray-100 dark:divide-gray-800">
|
| 65 |
+
<div class="px-6 py-4 text-sm text-gray-500">Loading...</div>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<!-- Stream Analytics Section -->
|
| 71 |
+
<div class="mb-10">
|
| 72 |
+
<div class="flex items-center justify-between mb-4">
|
| 73 |
+
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Recent Stream Analytics</h2>
|
| 74 |
+
<button onclick="clearAnalytics()"
|
| 75 |
+
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors shadow-sm flex items-center">
|
| 76 |
+
<i class="fas fa-trash-alt mr-2"></i> Clear Analytics
|
| 77 |
+
</button>
|
| 78 |
+
</div>
|
| 79 |
+
<div class="theme-card border rounded-xl overflow-hidden shadow-sm">
|
| 80 |
+
<div class="overflow-x-auto">
|
| 81 |
+
<table class="w-full text-sm text-left">
|
| 82 |
+
<thead class="text-xs uppercase bg-gray-50 dark:bg-gray-800 dark:text-gray-300 border-b">
|
| 83 |
+
<tr>
|
| 84 |
+
<th class="px-6 py-4 font-semibold">Stream ID</th>
|
| 85 |
+
<th class="px-6 py-4 font-semibold">Title</th>
|
| 86 |
+
<th class="px-6 py-4 font-semibold text-center">Bot (DC)</th>
|
| 87 |
+
<th class="px-6 py-4 font-semibold text-right">Data Transferred</th>
|
| 88 |
+
<th class="px-6 py-4 font-semibold text-right">Avg / Peak Speed</th>
|
| 89 |
+
<th class="px-6 py-4 font-semibold text-center">Duration</th>
|
| 90 |
+
<th class="px-6 py-4 font-semibold text-center">Status</th>
|
| 91 |
+
<th class="px-6 py-4 font-semibold text-right">Time</th>
|
| 92 |
+
</tr>
|
| 93 |
+
</thead>
|
| 94 |
+
<tbody id="analytics-tbody" class="divide-y divide-gray-100 dark:divide-gray-800">
|
| 95 |
+
<tr>
|
| 96 |
+
<td colspan="8" class="px-6 py-8 text-center text-gray-500">
|
| 97 |
+
<i class="fas fa-spinner fa-spin mr-2"></i> Loading analytics...
|
| 98 |
+
</td>
|
| 99 |
+
</tr>
|
| 100 |
+
</tbody>
|
| 101 |
+
</table>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<!-- Dead Links Section -->
|
| 107 |
+
<div>
|
| 108 |
+
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Flagged Dead Links</h2>
|
| 109 |
+
<div class="theme-card border rounded-xl overflow-hidden shadow-sm">
|
| 110 |
+
<div class="overflow-x-auto">
|
| 111 |
+
<table class="w-full text-sm text-left">
|
| 112 |
+
<thead class="text-xs uppercase bg-gray-50 dark:bg-gray-800 dark:text-gray-300 border-b">
|
| 113 |
+
<tr>
|
| 114 |
+
<th class="px-6 py-4 font-semibold">Title</th>
|
| 115 |
+
<th class="px-6 py-4 font-semibold w-24">Type</th>
|
| 116 |
+
<th class="px-6 py-4 font-semibold">Quality Details</th>
|
| 117 |
+
<th class="px-6 py-4 font-semibold w-32 text-center">Date Added</th>
|
| 118 |
+
<th class="px-6 py-4 font-semibold w-24 text-center">Action</th>
|
| 119 |
+
</tr>
|
| 120 |
+
</thead>
|
| 121 |
+
<tbody id="dead-links-tbody" class="divide-y divide-gray-100 dark:divide-gray-800">
|
| 122 |
+
<tr>
|
| 123 |
+
<td colspan="5" class="px-6 py-8 text-center text-gray-500">
|
| 124 |
+
<i class="fas fa-spinner fa-spin mr-2"></i> Loading dead links...
|
| 125 |
+
</td>
|
| 126 |
+
</tr>
|
| 127 |
+
</tbody>
|
| 128 |
+
</table>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
<script>
|
| 135 |
+
async function fetchStats() {
|
| 136 |
+
try {
|
| 137 |
+
const res = await fetch('/api/admin/system-stats');
|
| 138 |
+
if (res.ok) {
|
| 139 |
+
const data = await res.json();
|
| 140 |
+
document.getElementById('stat-cache-size').textContent = data.cache_size;
|
| 141 |
+
document.getElementById('stat-bot-count').textContent = data.total_bots;
|
| 142 |
+
|
| 143 |
+
let wlHtml = '';
|
| 144 |
+
if (data.bot_workloads && data.bot_workloads.length > 0) {
|
| 145 |
+
data.bot_workloads.forEach(b => {
|
| 146 |
+
let statusColor = 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
|
| 147 |
+
if (b.status === 'degraded') statusColor = 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
|
| 148 |
+
if (b.status === 'failing') statusColor = 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
|
| 149 |
+
|
| 150 |
+
wlHtml += `
|
| 151 |
+
<div class="flex items-center justify-between px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
| 152 |
+
<div class="flex items-center gap-3">
|
| 153 |
+
<span class="font-semibold text-gray-900 dark:text-white">${b.display_name}</span>
|
| 154 |
+
<span class="text-[10px] px-2 py-0.5 rounded-full uppercase font-bold tracking-wider ${statusColor}">${b.status}</span>
|
| 155 |
+
</div>
|
| 156 |
+
<div class="flex items-center gap-6 text-sm">
|
| 157 |
+
<div class="text-center">
|
| 158 |
+
<div class="text-xs text-gray-500 mb-0.5">Active Streams</div>
|
| 159 |
+
<div class="font-bold text-gray-900 dark:text-white">${b.current_load}</div>
|
| 160 |
+
</div>
|
| 161 |
+
<div class="text-center">
|
| 162 |
+
<div class="text-xs text-gray-500 mb-0.5">Avg Speed</div>
|
| 163 |
+
<div class="font-bold text-gray-900 dark:text-white">${b.avg_mbps} <span class="font-normal text-xs text-gray-400">MB/s</span></div>
|
| 164 |
+
</div>
|
| 165 |
+
<div class="text-center">
|
| 166 |
+
<div class="text-xs text-gray-500 mb-0.5">Recent Failures</div>
|
| 167 |
+
<div class="font-bold ${b.failures > 0 ? 'text-red-500' : 'text-gray-400'}">${b.failures}</div>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
`;
|
| 172 |
+
});
|
| 173 |
+
} else {
|
| 174 |
+
wlHtml = '<div class="px-6 py-8 text-center text-gray-500">No bot data available</div>';
|
| 175 |
+
}
|
| 176 |
+
document.getElementById('stat-workloads').innerHTML = wlHtml;
|
| 177 |
+
}
|
| 178 |
+
} catch (err) {
|
| 179 |
+
console.error(err);
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
async function fetchAnalytics() {
|
| 184 |
+
try {
|
| 185 |
+
const res = await fetch('/api/admin/stream-analytics');
|
| 186 |
+
const tbody = document.getElementById('analytics-tbody');
|
| 187 |
+
if (res.ok) {
|
| 188 |
+
const data = await res.json();
|
| 189 |
+
const streams = data.data.recent;
|
| 190 |
+
|
| 191 |
+
if (!streams || streams.length === 0) {
|
| 192 |
+
tbody.innerHTML = '<tr><td colspan="8" class="px-6 py-8 text-center text-gray-500">No recent streams recorded.</td></tr>';
|
| 193 |
+
return;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
let html = '';
|
| 197 |
+
streams.forEach(s => {
|
| 198 |
+
const mbpsStr = `${s.avg_mbps.toFixed(1)} / <span class="text-gray-400">${s.peak_mbps.toFixed(1)}</span>`;
|
| 199 |
+
const bytesStr = (s.total_bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
| 200 |
+
const timeStr = new Date(s.logged_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
| 201 |
+
const durationStr = s.duration_sec ? `${s.duration_sec.toFixed(1)}s` : 'β';
|
| 202 |
+
const titleStr = s.title || s.meta?.title || 'β';
|
| 203 |
+
|
| 204 |
+
let statusColor = 'text-green-500';
|
| 205 |
+
if (s.status === 'error') statusColor = 'text-red-500';
|
| 206 |
+
if (s.status === 'cancelled') statusColor = 'text-yellow-500';
|
| 207 |
+
|
| 208 |
+
html += `
|
| 209 |
+
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
| 210 |
+
<td class="px-6 py-3 font-mono text-xs text-gray-500 truncate max-w-[120px]" title="${s.stream_id}">${s.stream_id}</td>
|
| 211 |
+
<td class="px-6 py-3 text-sm font-medium text-gray-800 dark:text-gray-200 max-w-[160px] truncate" title="${titleStr}">${titleStr}</td>
|
| 212 |
+
<td class="px-6 py-3 text-center">Bot ${s.client_index + 1} <span class="text-xs text-gray-400 ml-1">(DC${s.dc_id})</span></td>
|
| 213 |
+
<td class="px-6 py-3 text-right font-medium">${bytesStr}</td>
|
| 214 |
+
<td class="px-6 py-3 text-right font-medium text-gray-700 dark:text-gray-300">${mbpsStr} <span class="text-xs text-gray-500">MB/s</span></td>
|
| 215 |
+
<td class="px-6 py-3 text-center text-gray-500">${durationStr}</td>
|
| 216 |
+
<td class="px-6 py-3 text-center capitalize text-xs font-bold ${statusColor}">${s.status}</td>
|
| 217 |
+
<td class="px-6 py-3 text-right text-gray-500 text-xs">${timeStr}</td>
|
| 218 |
+
</tr>
|
| 219 |
+
`;
|
| 220 |
+
});
|
| 221 |
+
tbody.innerHTML = html;
|
| 222 |
+
}
|
| 223 |
+
} catch (err) {
|
| 224 |
+
console.error(err);
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
async function fetchDeadLinks() {
|
| 229 |
+
try {
|
| 230 |
+
const res = await fetch('/api/admin/dead-links');
|
| 231 |
+
const tbody = document.getElementById('dead-links-tbody');
|
| 232 |
+
if (res.ok) {
|
| 233 |
+
const data = await res.json();
|
| 234 |
+
const links = data.data;
|
| 235 |
+
|
| 236 |
+
if (links.length === 0) {
|
| 237 |
+
tbody.innerHTML = '<tr><td colspan="5" class="px-6 py-8 text-center text-gray-500">No dead links found. All files are healthy!</td></tr>';
|
| 238 |
+
return;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
let html = '';
|
| 242 |
+
links.forEach(l => {
|
| 243 |
+
const typeBadge = l.type === 'movie'
|
| 244 |
+
? '<span class="px-2 py-1 rounded bg-blue-100 text-blue-800 text-xs font-medium">Movie</span>'
|
| 245 |
+
: '<span class="px-2 py-1 rounded bg-purple-100 text-purple-800 text-xs font-medium">TV Series</span>';
|
| 246 |
+
|
| 247 |
+
html += `
|
| 248 |
+
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
| 249 |
+
<td class="px-6 py-4">
|
| 250 |
+
<div class="font-medium text-gray-900 dark:text-gray-100">${l.title}</div>
|
| 251 |
+
<div class="text-xs text-gray-500">${l.year || ''}</div>
|
| 252 |
+
</td>
|
| 253 |
+
<td class="px-6 py-4">${typeBadge}</td>
|
| 254 |
+
<td class="px-6 py-4">
|
| 255 |
+
<div class="text-sm font-medium text-red-600 dark:text-red-400">${l.quality}</div>
|
| 256 |
+
<div class="text-xs text-gray-500">${l.size || 'Unknown size'}</div>
|
| 257 |
+
</td>
|
| 258 |
+
<td class="px-6 py-4 text-center text-gray-500 text-xs">${l.date_added ? new Date(l.date_added).toLocaleDateString() : 'β'}</td>
|
| 259 |
+
<td class="px-6 py-4 text-center">
|
| 260 |
+
<button onclick="deleteDeadLink(${l.tmdb_id}, ${l.db_index}, '${l.type}', '${l.quality_id}', ${l.season || -1}, ${l.episode || -1})"
|
| 261 |
+
class="text-red-600 hover:text-red-800 bg-red-50 hover:bg-red-100 rounded p-2 transition-colors" title="Delete from Database">
|
| 262 |
+
<i class="fas fa-trash"></i>
|
| 263 |
+
</button>
|
| 264 |
+
</td>
|
| 265 |
+
</tr>
|
| 266 |
+
`;
|
| 267 |
+
});
|
| 268 |
+
tbody.innerHTML = html;
|
| 269 |
+
}
|
| 270 |
+
} catch (err) {
|
| 271 |
+
console.error(err);
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
async function clearCache() {
|
| 276 |
+
if (!confirm("Are you sure you want to clear the Telegram FileId cache? This will force bots to re-fetch metadata on the next stream.")) return;
|
| 277 |
+
|
| 278 |
+
try {
|
| 279 |
+
const res = await fetch('/api/admin/clear-cache', { method: 'POST' });
|
| 280 |
+
if (res.ok) {
|
| 281 |
+
const data = await res.json();
|
| 282 |
+
alert(data.message);
|
| 283 |
+
fetchStats();
|
| 284 |
+
} else {
|
| 285 |
+
alert("Failed to clear cache.");
|
| 286 |
+
}
|
| 287 |
+
} catch (err) {
|
| 288 |
+
alert("Error clearing cache.");
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
async function clearAnalytics() {
|
| 293 |
+
if (!confirm("Are you sure you want to clear all stream analytics history? This cannot be undone.")) return;
|
| 294 |
+
|
| 295 |
+
try {
|
| 296 |
+
const res = await fetch('/api/admin/clear-analytics', { method: 'POST' });
|
| 297 |
+
if (res.ok) {
|
| 298 |
+
const data = await res.json();
|
| 299 |
+
alert(data.message || 'Analytics cleared successfully.');
|
| 300 |
+
fetchAnalytics();
|
| 301 |
+
} else {
|
| 302 |
+
const err = await res.json().catch(() => ({}));
|
| 303 |
+
alert(err.detail || 'Failed to clear analytics.');
|
| 304 |
+
}
|
| 305 |
+
} catch (err) {
|
| 306 |
+
alert('Error clearing analytics.');
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
async function deleteDeadLink(tmdb_id, db_index, type, quality_id, season, episode) {
|
| 311 |
+
if (!confirm("Are you sure you want to purge this dead link from the database? Users will no longer see this quality in Stremio.")) return;
|
| 312 |
+
|
| 313 |
+
try {
|
| 314 |
+
let endpoint = '';
|
| 315 |
+
if (type === 'movie') {
|
| 316 |
+
endpoint = `/api/media/delete-quality?tmdb_id=${tmdb_id}&db_index=${db_index}&id=${quality_id}`;
|
| 317 |
+
} else {
|
| 318 |
+
endpoint = `/api/media/delete-tv-quality?tmdb_id=${tmdb_id}&db_index=${db_index}&season=${season}&episode=${episode}&id=${quality_id}`;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
const res = await fetch(endpoint, { method: 'DELETE' });
|
| 322 |
+
if (res.ok) {
|
| 323 |
+
fetchDeadLinks(); // Refresh table
|
| 324 |
+
} else {
|
| 325 |
+
alert("Failed to delete the dead link.");
|
| 326 |
+
}
|
| 327 |
+
} catch (err) {
|
| 328 |
+
alert("Error communicating with server.");
|
| 329 |
+
}
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
// Initial fetch on load
|
| 333 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 334 |
+
fetchStats();
|
| 335 |
+
fetchAnalytics();
|
| 336 |
+
fetchDeadLinks();
|
| 337 |
+
// Refresh dynamic stats
|
| 338 |
+
setInterval(fetchStats, 10000);
|
| 339 |
+
setInterval(fetchAnalytics, 15000);
|
| 340 |
+
});
|
| 341 |
+
</script>
|
| 342 |
+
{% endblock %}
|
Backend/fastapi/templates/base.html
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
<html lang="en">
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
<title>{% block title %}Telegram Stremio{% endblock %}</title>
|
| 7 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" />
|
| 9 |
-
|
| 10 |
<script>
|
| 11 |
tailwind.config = {
|
| 12 |
theme: {
|
|
@@ -25,14 +26,76 @@
|
|
| 25 |
}
|
| 26 |
</script>
|
| 27 |
<style>
|
| 28 |
-
body {
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
</style>
|
| 35 |
</head>
|
|
|
|
| 36 |
<body class="min-h-screen">
|
| 37 |
<!-- Navigation -->
|
| 38 |
<nav class="theme-card shadow-lg border-b">
|
|
@@ -42,33 +105,40 @@
|
|
| 42 |
<h1 class="text-2xl font-bold text-primary">Telegram Stremio</h1>
|
| 43 |
{% if current_user %}
|
| 44 |
<div class="hidden md:flex space-x-4">
|
| 45 |
-
<a href="/"
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
</div>
|
| 48 |
{% endif %}
|
| 49 |
</div>
|
| 50 |
-
|
| 51 |
<div class="flex items-center space-x-4">
|
| 52 |
<!-- Theme Selector -->
|
| 53 |
<div class="relative">
|
| 54 |
<form method="post" action="/set-theme" id="theme-form">
|
| 55 |
-
<select name="theme" onchange="document.getElementById('theme-form').submit()"
|
|
|
|
| 56 |
{% for theme_key, theme_data in themes.items() %}
|
| 57 |
-
<option value="{{ theme_key }}" {% if current_theme
|
| 58 |
{{ theme_data.name }}
|
| 59 |
</option>
|
| 60 |
{% endfor %}
|
| 61 |
</select>
|
| 62 |
</form>
|
| 63 |
</div>
|
| 64 |
-
|
| 65 |
{% if current_user %}
|
| 66 |
<div class="flex items-center space-x-4">
|
| 67 |
<span class="text-sm theme-text-secondary">{{ current_user }}</span>
|
| 68 |
-
<a href="/logout"
|
|
|
|
| 69 |
</div>
|
| 70 |
{% else %}
|
| 71 |
-
<a href="/login"
|
|
|
|
| 72 |
{% endif %}
|
| 73 |
</div>
|
| 74 |
</div>
|
|
@@ -86,11 +156,14 @@
|
|
| 86 |
<div class="flex justify-between items-center">
|
| 87 |
<p class="theme-text-secondary text-sm">Β© 2025 Telegram Stremio. All rights reserved.</p>
|
| 88 |
<div class="flex space-x-4">
|
| 89 |
-
<a href="/status"
|
| 90 |
-
|
|
|
|
|
|
|
| 91 |
</div>
|
| 92 |
</div>
|
| 93 |
</div>
|
| 94 |
</footer>
|
| 95 |
</body>
|
| 96 |
-
|
|
|
|
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
<html lang="en">
|
| 3 |
+
|
| 4 |
<head>
|
| 5 |
<meta charset="UTF-8">
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
<title>{% block title %}Telegram Stremio{% endblock %}</title>
|
| 8 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 9 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" />
|
| 10 |
+
|
| 11 |
<script>
|
| 12 |
tailwind.config = {
|
| 13 |
theme: {
|
|
|
|
| 26 |
}
|
| 27 |
</script>
|
| 28 |
<style>
|
| 29 |
+
body {
|
| 30 |
+
background-color: {
|
| 31 |
+
{
|
| 32 |
+
theme.colors.background
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
;
|
| 37 |
+
|
| 38 |
+
color: {
|
| 39 |
+
{
|
| 40 |
+
theme.colors.text
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.theme-card {
|
| 48 |
+
background-color: {
|
| 49 |
+
{
|
| 50 |
+
theme.colors.card
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.theme-primary {
|
| 58 |
+
background-color: {
|
| 59 |
+
{
|
| 60 |
+
theme.colors.primary
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.theme-secondary {
|
| 68 |
+
background-color: {
|
| 69 |
+
{
|
| 70 |
+
theme.colors.secondary
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.theme-accent {
|
| 78 |
+
background-color: {
|
| 79 |
+
{
|
| 80 |
+
theme.colors.accent
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.theme-text-secondary {
|
| 88 |
+
color: {
|
| 89 |
+
{
|
| 90 |
+
theme.colors.text_secondary
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
;
|
| 95 |
+
}
|
| 96 |
</style>
|
| 97 |
</head>
|
| 98 |
+
|
| 99 |
<body class="min-h-screen">
|
| 100 |
<!-- Navigation -->
|
| 101 |
<nav class="theme-card shadow-lg border-b">
|
|
|
|
| 105 |
<h1 class="text-2xl font-bold text-primary">Telegram Stremio</h1>
|
| 106 |
{% if current_user %}
|
| 107 |
<div class="hidden md:flex space-x-4">
|
| 108 |
+
<a href="/"
|
| 109 |
+
class="text-text hover:text-primary px-3 py-2 rounded-md transition-colors">Dashboard</a>
|
| 110 |
+
<a href="/media/manage"
|
| 111 |
+
class="text-text hover:text-primary px-3 py-2 rounded-md transition-colors">Media</a>
|
| 112 |
+
<a href="/admin/dashboard"
|
| 113 |
+
class="text-text hover:text-primary px-3 py-2 rounded-md transition-colors">Admin</a>
|
| 114 |
</div>
|
| 115 |
{% endif %}
|
| 116 |
</div>
|
| 117 |
+
|
| 118 |
<div class="flex items-center space-x-4">
|
| 119 |
<!-- Theme Selector -->
|
| 120 |
<div class="relative">
|
| 121 |
<form method="post" action="/set-theme" id="theme-form">
|
| 122 |
+
<select name="theme" onchange="document.getElementById('theme-form').submit()"
|
| 123 |
+
class="theme-card border border-gray-300 rounded-md px-3 py-2 text-sm">
|
| 124 |
{% for theme_key, theme_data in themes.items() %}
|
| 125 |
+
<option value="{{ theme_key }}" {% if current_theme==theme_key %}selected{% endif %}>
|
| 126 |
{{ theme_data.name }}
|
| 127 |
</option>
|
| 128 |
{% endfor %}
|
| 129 |
</select>
|
| 130 |
</form>
|
| 131 |
</div>
|
| 132 |
+
|
| 133 |
{% if current_user %}
|
| 134 |
<div class="flex items-center space-x-4">
|
| 135 |
<span class="text-sm theme-text-secondary">{{ current_user }}</span>
|
| 136 |
+
<a href="/logout"
|
| 137 |
+
class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 transition-colors">Logout</a>
|
| 138 |
</div>
|
| 139 |
{% else %}
|
| 140 |
+
<a href="/login"
|
| 141 |
+
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md transition-colors">Login</a>
|
| 142 |
{% endif %}
|
| 143 |
</div>
|
| 144 |
</div>
|
|
|
|
| 156 |
<div class="flex justify-between items-center">
|
| 157 |
<p class="theme-text-secondary text-sm">Β© 2025 Telegram Stremio. All rights reserved.</p>
|
| 158 |
<div class="flex space-x-4">
|
| 159 |
+
<a href="/status"
|
| 160 |
+
class="theme-text-secondary hover:text-primary text-sm transition-colors">Status</a>
|
| 161 |
+
<a href="/stremio" class="theme-text-secondary hover:text-primary text-sm transition-colors">Stremio
|
| 162 |
+
Guide</a>
|
| 163 |
</div>
|
| 164 |
</div>
|
| 165 |
</div>
|
| 166 |
</footer>
|
| 167 |
</body>
|
| 168 |
+
|
| 169 |
+
</html>
|
Backend/fastapi/templates/dashboard.html
CHANGED
|
@@ -12,7 +12,7 @@
|
|
| 12 |
<h1 class="text-3xl font-bold text-primary">Dashboard</h1>
|
| 13 |
</div>
|
| 14 |
<button onclick="refreshSystemStats()"
|
| 15 |
-
class="flex items-center space-x-2 px-4 py-2 rounded-lg
|
| 16 |
title="Refresh System Stats">
|
| 17 |
<svg id="refresh-icon" xmlns="http://www.w3.org/2000/svg"
|
| 18 |
class="w-5 h-5 group-hover:rotate-180 transition-transform duration-500" fill="none"
|
|
@@ -71,14 +71,19 @@
|
|
| 71 |
</div>
|
| 72 |
|
| 73 |
<!-- Bot Workload Section -->
|
| 74 |
-
|
| 75 |
<div class="streams-container">
|
| 76 |
<div class="theme-card rounded-xl p-8 shadow-xl">
|
| 77 |
<div class="flex items-center mb-6">
|
| 78 |
-
<div
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
</svg>
|
| 83 |
</div>
|
| 84 |
<div class="flex-1">
|
|
@@ -91,82 +96,127 @@
|
|
| 91 |
|
| 92 |
<div class="space-y-4">
|
| 93 |
{% if system_stats.active_streams and system_stats.active_streams|length > 0 %}
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
<
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
</div>
|
| 104 |
-
<span class="px-2 py-1 rounded text-xs font-semibold {% if stream.status == 'active' %}bg-green-100 text-green-800{% elif stream.status == 'cancelled' %}bg-yellow-100 text-yellow-800{% else %}bg-red-100 text-red-800{% endif %}">
|
| 105 |
-
{{ stream.status|upper }}
|
| 106 |
-
</span>
|
| 107 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
</div>
|
| 122 |
-
|
| 123 |
-
<div class="flex justify-between py-2 px-3 bg-white rounded col-span-2">
|
| 124 |
-
<span class="text-sm theme-text-secondary">Data Transferred:</span>
|
| 125 |
-
<span class="text-sm font-semibold">{{ "%.2f"|format(stream.total_bytes / 1024 / 1024) }} MB</span>
|
| 126 |
-
</div>
|
| 127 |
</div>
|
| 128 |
|
| 129 |
-
<div class="
|
| 130 |
-
<
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
<div class="text-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
| 142 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
<
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
<span class="font-mono text-xs">{{ stream.meta.client_host }}</span>
|
| 152 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
</div>
|
| 154 |
-
{% endif %}
|
| 155 |
</div>
|
| 156 |
-
{%
|
|
|
|
|
|
|
| 157 |
{% else %}
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
<
|
| 164 |
-
<
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
{% endif %}
|
| 167 |
</div>
|
| 168 |
</div>
|
| 169 |
-
</div>
|
| 170 |
</div>
|
| 171 |
</div>
|
| 172 |
|
|
@@ -219,13 +269,13 @@
|
|
| 219 |
</div>
|
| 220 |
{% endfor %}
|
| 221 |
{% else %}
|
| 222 |
-
|
| 223 |
-
{% endif %}
|
| 224 |
-
</div>
|
| 225 |
</div>
|
| 226 |
|
| 227 |
|
| 228 |
-
|
| 229 |
<!-- Media Management Section -->
|
| 230 |
<div class="mb-8">
|
| 231 |
<div class="grid lg:grid-cols-2 gap-6">
|
|
@@ -293,7 +343,7 @@
|
|
| 293 |
<div class="mb-8">
|
| 294 |
<div id="create-btn-container">
|
| 295 |
<button onclick="toggleCreateForm()"
|
| 296 |
-
class="
|
| 297 |
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24"
|
| 298 |
stroke="currentColor">
|
| 299 |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
@@ -336,7 +386,7 @@
|
|
| 336 |
Cancel
|
| 337 |
</button>
|
| 338 |
<button type="submit"
|
| 339 |
-
class="w-full md:w-auto
|
| 340 |
Create
|
| 341 |
</button>
|
| 342 |
</div>
|
|
@@ -457,7 +507,7 @@
|
|
| 457 |
<button onclick="closeEditModal()"
|
| 458 |
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">Cancel</button>
|
| 459 |
<button onclick="saveTokenLimits()"
|
| 460 |
-
class="px-4 py-2
|
| 461 |
Changes</button>
|
| 462 |
</div>
|
| 463 |
</div>
|
|
@@ -600,6 +650,9 @@
|
|
| 600 |
|
| 601 |
// Update Bot Workloads
|
| 602 |
await updateBotWorkloads();
|
|
|
|
|
|
|
|
|
|
| 603 |
}
|
| 604 |
} catch (error) {
|
| 605 |
console.error("Failed to refresh system stats:", error);
|
|
@@ -611,6 +664,148 @@
|
|
| 611 |
}
|
| 612 |
}
|
| 613 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
// Auto-refresh every 2 minutes
|
| 615 |
const statsInterval = setInterval(refreshSystemStats, 120000);
|
| 616 |
</script>
|
|
@@ -684,7 +879,7 @@
|
|
| 684 |
|
| 685 |
function showToast(message, duration = 3000) {
|
| 686 |
const toast = document.createElement('div');
|
| 687 |
-
toast.className = 'fixed top-4 right-4
|
| 688 |
toast.textContent = message;
|
| 689 |
document.body.appendChild(toast);
|
| 690 |
|
|
@@ -722,6 +917,14 @@
|
|
| 722 |
document.querySelectorAll('[data-bytes]').forEach(el => {
|
| 723 |
el.textContent = formatBytes(el.getAttribute('data-bytes'));
|
| 724 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 725 |
});
|
| 726 |
</script>
|
| 727 |
-
{% endblock %}
|
|
|
|
| 12 |
<h1 class="text-3xl font-bold text-primary">Dashboard</h1>
|
| 13 |
</div>
|
| 14 |
<button onclick="refreshSystemStats()"
|
| 15 |
+
class="flex items-center space-x-2 px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-all shadow-md active:scale-95 group focus:outline-none"
|
| 16 |
title="Refresh System Stats">
|
| 17 |
<svg id="refresh-icon" xmlns="http://www.w3.org/2000/svg"
|
| 18 |
class="w-5 h-5 group-hover:rotate-180 transition-transform duration-500" fill="none"
|
|
|
|
| 71 |
</div>
|
| 72 |
|
| 73 |
<!-- Bot Workload Section -->
|
| 74 |
+
<!-- Live Streaming Status Card -->
|
| 75 |
<div class="streams-container">
|
| 76 |
<div class="theme-card rounded-xl p-8 shadow-xl">
|
| 77 |
<div class="flex items-center mb-6">
|
| 78 |
+
<div
|
| 79 |
+
class="w-12 h-12 theme-primary rounded-lg flex items-center justify-center mr-4 bg-opacity-20 text-primary">
|
| 80 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24"
|
| 81 |
+
stroke="white">
|
| 82 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
| 83 |
+
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z">
|
| 84 |
+
</path>
|
| 85 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
| 86 |
+
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
| 87 |
</svg>
|
| 88 |
</div>
|
| 89 |
<div class="flex-1">
|
|
|
|
| 96 |
|
| 97 |
<div class="space-y-4">
|
| 98 |
{% if system_stats.active_streams and system_stats.active_streams|length > 0 %}
|
| 99 |
+
{% for stream in system_stats.active_streams %}
|
| 100 |
+
<div
|
| 101 |
+
class="p-4 bg-gray-50 rounded-lg border-l-4 {% if stream.status == 'active' %}border-green-500{% elif stream.status == 'cancelled' %}border-yellow-500{% else %}border-red-500{% endif %}">
|
| 102 |
+
<div class="flex justify-between items-start mb-3">
|
| 103 |
+
<div class="flex items-center gap-2 max-w-[70%]">
|
| 104 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-primary shrink-0"
|
| 105 |
+
fill="currentColor" viewBox="0 0 24 24">
|
| 106 |
+
<path d="M8 5v14l11-7z" />
|
| 107 |
+
</svg>
|
| 108 |
+
<div class="truncate">
|
| 109 |
+
<span class="font-semibold"
|
| 110 |
+
title="{{ stream.meta.get('title') or stream.stream_id }}">{{
|
| 111 |
+
stream.meta.get('title') or
|
| 112 |
+
'Unknown Stream' }}</span>
|
| 113 |
+
<div class="text-[10px] text-gray-500 font-mono mt-0.5"
|
| 114 |
+
title="{{ stream.stream_id }}">
|
| 115 |
+
ID: {{ stream.stream_id[:8] }}...
|
| 116 |
+
</div>
|
| 117 |
</div>
|
|
|
|
|
|
|
|
|
|
| 118 |
</div>
|
| 119 |
+
<span
|
| 120 |
+
class="px-2 py-1 rounded text-xs font-semibold shrink-0 {% if stream.status == 'active' %}bg-green-100 text-green-800{% elif stream.status == 'cancelled' %}bg-yellow-100 text-yellow-800{% else %}bg-red-100 text-red-800{% endif %}">
|
| 121 |
+
{{ stream.status|upper }}
|
| 122 |
+
</span>
|
| 123 |
+
</div>
|
| 124 |
|
| 125 |
+
<div class="grid grid-cols-2 gap-3 mb-3">
|
| 126 |
+
<div class="flex justify-between py-2 px-3 bg-white rounded">
|
| 127 |
+
<span class="text-sm theme-text-secondary">Bot:</span>
|
| 128 |
+
<span class="text-sm font-semibold">Bot{{ stream.client_index + 1 }} </span>
|
| 129 |
+
</div>
|
| 130 |
+
<div class="flex justify-between py-2 px-3 bg-white rounded">
|
| 131 |
+
<span class="text-sm theme-text-secondary">File:</span>
|
| 132 |
+
<span class="text-sm font-semibold">DC{{ stream.dc_id }}</span>
|
| 133 |
+
</div>
|
| 134 |
+
<div class="flex justify-between py-2 px-3 bg-white rounded col-span-2">
|
| 135 |
+
<span class="text-sm theme-text-secondary">Duration:</span>
|
| 136 |
+
<span class="text-sm font-semibold">{{ stream.duration }}s</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
</div>
|
| 138 |
|
| 139 |
+
<div class="flex justify-between py-2 px-3 bg-white rounded col-span-2">
|
| 140 |
+
<span class="text-sm theme-text-secondary">Data Transferred:</span>
|
| 141 |
+
<span class="text-sm font-semibold">{{ "%.2f"|format(stream.total_bytes / 1024 /
|
| 142 |
+
1024) }} MB</span>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<div class="grid grid-cols-3 gap-3">
|
| 147 |
+
<div class="text-center py-3 bg-white rounded">
|
| 148 |
+
<div class="text-xs theme-text-secondary mb-1">Instant</div>
|
| 149 |
+
<div class="text-sm font-bold text-primary">{{ stream.instant_mbps }} MB/s</div>
|
| 150 |
+
</div>
|
| 151 |
+
<div class="text-center py-3 bg-white rounded">
|
| 152 |
+
<div class="text-xs theme-text-secondary mb-1">Average</div>
|
| 153 |
+
<div class="text-sm font-bold text-primary">{{ stream.avg_mbps }} MB/s</div>
|
| 154 |
</div>
|
| 155 |
+
<div class="text-center py-3 bg-white rounded">
|
| 156 |
+
<div class="text-xs theme-text-secondary mb-1">Peak</div>
|
| 157 |
+
<div class="text-sm font-bold text-primary">{{ stream.peak_mbps }} MB/s</div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
|
| 161 |
+
{% if stream.meta and (stream.meta.client_host or stream.meta.user_name) %}
|
| 162 |
+
<div class="mt-3 pt-3 border-t border-gray-200">
|
| 163 |
+
<div class="flex flex-col gap-1">
|
| 164 |
+
{% if stream.meta.client_host %}
|
| 165 |
+
<div class="flex justify-between items-center text-sm">
|
| 166 |
+
<div class="flex items-center gap-2">
|
| 167 |
+
<svg xmlns="http://www.w3.org/2000/svg"
|
| 168 |
+
class="w-4 h-4 theme-text-secondary" fill="none" viewBox="0 0 24 24"
|
| 169 |
+
stroke="currentColor">
|
| 170 |
+
<path stroke-linecap="round" stroke-linejoin="round"
|
| 171 |
+
stroke-width="2"
|
| 172 |
+
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9">
|
| 173 |
+
</path>
|
| 174 |
+
</svg>
|
| 175 |
+
<span class="theme-text-secondary">Client IPv4:</span>
|
| 176 |
+
</div>
|
| 177 |
<span class="font-mono text-xs">{{ stream.meta.client_host }}</span>
|
| 178 |
</div>
|
| 179 |
+
{% endif %}
|
| 180 |
+
{% if stream.meta.user_name %}
|
| 181 |
+
<div class="flex justify-between items-center text-sm">
|
| 182 |
+
<div class="flex items-center gap-2">
|
| 183 |
+
<svg xmlns="http://www.w3.org/2000/svg"
|
| 184 |
+
class="w-4 h-4 theme-text-secondary" fill="none" viewBox="0 0 24 24"
|
| 185 |
+
stroke="currentColor">
|
| 186 |
+
<path stroke-linecap="round" stroke-linejoin="round"
|
| 187 |
+
stroke-width="2"
|
| 188 |
+
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z">
|
| 189 |
+
</path>
|
| 190 |
+
</svg>
|
| 191 |
+
<span class="theme-text-secondary">User:</span>
|
| 192 |
+
</div>
|
| 193 |
+
<span class="font-mono text-xs font-semibold">{{ stream.meta.user_name
|
| 194 |
+
}}</span>
|
| 195 |
+
</div>
|
| 196 |
+
{% endif %}
|
| 197 |
</div>
|
|
|
|
| 198 |
</div>
|
| 199 |
+
{% endif %}
|
| 200 |
+
</div>
|
| 201 |
+
{% endfor %}
|
| 202 |
{% else %}
|
| 203 |
+
<div class="text-center py-12">
|
| 204 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 mx-auto mb-4 text-gray-300"
|
| 205 |
+
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 206 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
| 207 |
+
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z">
|
| 208 |
+
</path>
|
| 209 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
| 210 |
+
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
| 211 |
+
</svg>
|
| 212 |
+
<p class="text-gray-500 font-medium mb-1">No active streams</p>
|
| 213 |
+
<p class="text-gray-400 text-sm">Streams will appear here when users start watching
|
| 214 |
+
content</p>
|
| 215 |
+
</div>
|
| 216 |
{% endif %}
|
| 217 |
</div>
|
| 218 |
</div>
|
| 219 |
+
</div>
|
| 220 |
</div>
|
| 221 |
</div>
|
| 222 |
|
|
|
|
| 269 |
</div>
|
| 270 |
{% endfor %}
|
| 271 |
{% else %}
|
| 272 |
+
<div class="col-span-full text-center theme-text-secondary py-8">No database statistics available</div>
|
| 273 |
+
{% endif %} <!-- Add this closing tag -->
|
| 274 |
+
</div>
|
| 275 |
</div>
|
| 276 |
|
| 277 |
|
| 278 |
+
|
| 279 |
<!-- Media Management Section -->
|
| 280 |
<div class="mb-8">
|
| 281 |
<div class="grid lg:grid-cols-2 gap-6">
|
|
|
|
| 343 |
<div class="mb-8">
|
| 344 |
<div id="create-btn-container">
|
| 345 |
<button onclick="toggleCreateForm()"
|
| 346 |
+
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold transition-colors duration-200 flex items-center shadow-md">
|
| 347 |
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24"
|
| 348 |
stroke="currentColor">
|
| 349 |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
|
|
| 386 |
Cancel
|
| 387 |
</button>
|
| 388 |
<button type="submit"
|
| 389 |
+
class="w-full md:w-auto bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold transition-colors duration-200 flex items-center justify-center">
|
| 390 |
Create
|
| 391 |
</button>
|
| 392 |
</div>
|
|
|
|
| 507 |
<button onclick="closeEditModal()"
|
| 508 |
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">Cancel</button>
|
| 509 |
<button onclick="saveTokenLimits()"
|
| 510 |
+
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">Save
|
| 511 |
Changes</button>
|
| 512 |
</div>
|
| 513 |
</div>
|
|
|
|
| 650 |
|
| 651 |
// Update Bot Workloads
|
| 652 |
await updateBotWorkloads();
|
| 653 |
+
|
| 654 |
+
// Update Active Streams
|
| 655 |
+
await updateActiveStreams();
|
| 656 |
}
|
| 657 |
} catch (error) {
|
| 658 |
console.error("Failed to refresh system stats:", error);
|
|
|
|
| 664 |
}
|
| 665 |
}
|
| 666 |
|
| 667 |
+
async function updateActiveStreams() {
|
| 668 |
+
try {
|
| 669 |
+
const response = await fetch('/stream/stats');
|
| 670 |
+
if (response.ok) {
|
| 671 |
+
const data = await response.json();
|
| 672 |
+
const activeStreams = data.active_streams || [];
|
| 673 |
+
const container = document.getElementById('active-streams-container');
|
| 674 |
+
|
| 675 |
+
if (container) {
|
| 676 |
+
if (activeStreams.length === 0) {
|
| 677 |
+
container.innerHTML = `<div class="p-8 text-center bg-gray-50 border border-dashed rounded-lg">
|
| 678 |
+
<i class="fas fa-video-slash text-gray-300 text-3xl mb-3"></i>
|
| 679 |
+
<p class="text-gray-500 font-medium">No active streams at the moment</p>
|
| 680 |
+
<p class="text-xs text-gray-400 mt-1">Users are not currently watching any media.</p>
|
| 681 |
+
</div>`;
|
| 682 |
+
} else {
|
| 683 |
+
const escapeHtml = (unsafe) => {
|
| 684 |
+
if (!unsafe) return '';
|
| 685 |
+
return unsafe
|
| 686 |
+
.toString()
|
| 687 |
+
.replace(/&/g, "&")
|
| 688 |
+
.replace(/</g, "<")
|
| 689 |
+
.replace(/>/g, ">")
|
| 690 |
+
.replace(/"/g, """)
|
| 691 |
+
.replace(/'/g, "'");
|
| 692 |
+
};
|
| 693 |
+
|
| 694 |
+
const formatBytesStr = (bytes) => {
|
| 695 |
+
if (bytes === 0) return '0 B';
|
| 696 |
+
const k = 1024;
|
| 697 |
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
| 698 |
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
| 699 |
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
| 700 |
+
};
|
| 701 |
+
|
| 702 |
+
container.innerHTML = activeStreams.map(stream => {
|
| 703 |
+
let statusColor = 'bg-red-100 text-red-800';
|
| 704 |
+
let statusPulse = 'hidden';
|
| 705 |
+
if (stream.status === 'active') {
|
| 706 |
+
statusColor = 'bg-green-100 text-green-800';
|
| 707 |
+
statusPulse = '';
|
| 708 |
+
} else if (stream.status === 'cancelled') {
|
| 709 |
+
statusColor = 'bg-yellow-100 text-yellow-800';
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
const streamIdStr = stream.stream_id ? stream.stream_id.substring(0, 8) + '...' : 'Unknown';
|
| 713 |
+
const titleStr = (stream.meta && stream.meta.title) ? stream.meta.title : (stream.title || 'Unknown Stream');
|
| 714 |
+
|
| 715 |
+
return `
|
| 716 |
+
<div class="p-4 bg-gray-50 rounded-lg border-l-4 border-green-500">
|
| 717 |
+
<div class="flex justify-between items-start mb-3">
|
| 718 |
+
<div class="flex items-center gap-2 max-w-[70%]">
|
| 719 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-primary shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
| 720 |
+
<path d="M8 5v14l11-7z" />
|
| 721 |
+
</svg>
|
| 722 |
+
<div class="truncate">
|
| 723 |
+
<span class="font-semibold" title="${escapeHtml(stream.title || stream.stream_id)}">${escapeHtml(titleStr)}</span>
|
| 724 |
+
<div class="text-[10px] text-gray-500 font-mono mt-0.5" title="${escapeHtml(stream.stream_id)}">
|
| 725 |
+
ID: ${escapeHtml(streamIdStr)}
|
| 726 |
+
</div>
|
| 727 |
+
</div>
|
| 728 |
+
</div>
|
| 729 |
+
<span class="px-2 py-1 rounded text-xs font-semibold shrink-0 uppercase ${statusColor}">
|
| 730 |
+
${escapeHtml(stream.status)}
|
| 731 |
+
</span>
|
| 732 |
+
</div>
|
| 733 |
+
|
| 734 |
+
<div class="grid grid-cols-2 gap-3 mb-3">
|
| 735 |
+
<div class="flex justify-between py-2 px-3 bg-white rounded">
|
| 736 |
+
<span class="text-sm theme-text-secondary">Bot:</span>
|
| 737 |
+
<span class="text-sm font-semibold">Bot${(stream.client_index || 0) + 1} </span>
|
| 738 |
+
</div>
|
| 739 |
+
<div class="flex justify-between py-2 px-3 bg-white rounded">
|
| 740 |
+
<span class="text-sm theme-text-secondary">File:</span>
|
| 741 |
+
<span class="text-sm font-semibold">DC${stream.dc_id || '-'}</span>
|
| 742 |
+
</div>
|
| 743 |
+
<div class="flex justify-between py-2 px-3 bg-white rounded col-span-2">
|
| 744 |
+
<span class="text-sm theme-text-secondary">Duration:</span>
|
| 745 |
+
<span class="text-sm font-semibold">${stream.duration || 0}s</span>
|
| 746 |
+
</div>
|
| 747 |
+
|
| 748 |
+
<div class="flex justify-between py-2 px-3 bg-white rounded col-span-2">
|
| 749 |
+
<span class="text-sm theme-text-secondary">Data Transferred:</span>
|
| 750 |
+
<span class="text-sm font-semibold">${formatBytesStr(stream.total_bytes)}</span>
|
| 751 |
+
</div>
|
| 752 |
+
</div>
|
| 753 |
+
|
| 754 |
+
<div class="grid grid-cols-3 gap-3">
|
| 755 |
+
<div class="text-center py-3 bg-white rounded">
|
| 756 |
+
<div class="text-xs theme-text-secondary mb-1">Instant</div>
|
| 757 |
+
<div class="text-sm font-bold text-primary">${stream.instant_mbps || 0} MB/s</div>
|
| 758 |
+
</div>
|
| 759 |
+
<div class="text-center py-3 bg-white rounded">
|
| 760 |
+
<div class="text-xs theme-text-secondary mb-1">Average</div>
|
| 761 |
+
<div class="text-sm font-bold text-primary">${stream.avg_mbps || 0} MB/s</div>
|
| 762 |
+
</div>
|
| 763 |
+
<div class="text-center py-3 bg-white rounded">
|
| 764 |
+
<div class="text-xs theme-text-secondary mb-1">Peak</div>
|
| 765 |
+
<div class="text-sm font-bold text-primary">${stream.peak_mbps || 0} MB/s</div>
|
| 766 |
+
</div>
|
| 767 |
+
</div>
|
| 768 |
+
</div>
|
| 769 |
+
|
| 770 |
+
${stream.meta && (stream.meta.client_host || stream.meta.user_name) ? `
|
| 771 |
+
<div class="mt-3 pt-3 border-t border-gray-200">
|
| 772 |
+
<div class="flex flex-col gap-1">
|
| 773 |
+
${stream.meta.client_host ? `
|
| 774 |
+
<div class="flex justify-between items-center text-sm">
|
| 775 |
+
<div class="flex items-center gap-2">
|
| 776 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 theme-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 777 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
|
| 778 |
+
</svg>
|
| 779 |
+
<span class="theme-text-secondary">Client IPv4:</span>
|
| 780 |
+
</div>
|
| 781 |
+
<span class="font-mono text-xs">${escapeHtml(stream.meta.client_host)}</span>
|
| 782 |
+
</div>
|
| 783 |
+
` : ''}
|
| 784 |
+
${stream.meta.user_name ? `
|
| 785 |
+
<div class="flex justify-between items-center text-sm">
|
| 786 |
+
<div class="flex items-center gap-2">
|
| 787 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 theme-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 788 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
| 789 |
+
</svg>
|
| 790 |
+
<span class="theme-text-secondary">User:</span>
|
| 791 |
+
</div>
|
| 792 |
+
<span class="font-mono text-xs font-semibold">${escapeHtml(stream.meta.user_name)}</span>
|
| 793 |
+
</div>
|
| 794 |
+
` : ''}
|
| 795 |
+
</div>
|
| 796 |
+
</div>
|
| 797 |
+
` : ''}
|
| 798 |
+
</div>
|
| 799 |
+
`;
|
| 800 |
+
}).join('');
|
| 801 |
+
}
|
| 802 |
+
}
|
| 803 |
+
}
|
| 804 |
+
} catch (error) {
|
| 805 |
+
console.error("Failed to update active streams:", error);
|
| 806 |
+
}
|
| 807 |
+
}
|
| 808 |
+
|
| 809 |
// Auto-refresh every 2 minutes
|
| 810 |
const statsInterval = setInterval(refreshSystemStats, 120000);
|
| 811 |
</script>
|
|
|
|
| 879 |
|
| 880 |
function showToast(message, duration = 3000) {
|
| 881 |
const toast = document.createElement('div');
|
| 882 |
+
toast.className = 'fixed top-4 right-4 bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg z-50 transform transition-all duration-300 translate-x-full';
|
| 883 |
toast.textContent = message;
|
| 884 |
document.body.appendChild(toast);
|
| 885 |
|
|
|
|
| 917 |
document.querySelectorAll('[data-bytes]').forEach(el => {
|
| 918 |
el.textContent = formatBytes(el.getAttribute('data-bytes'));
|
| 919 |
});
|
| 920 |
+
|
| 921 |
+
// Store the first API token in localStorage so other pages (e.g. media edit) can build stream links
|
| 922 |
+
{% if api_tokens %}
|
| 923 |
+
const firstToken = '{{ api_tokens[0].token }}';
|
| 924 |
+
if (firstToken) {
|
| 925 |
+
localStorage.setItem('api_token', firstToken);
|
| 926 |
+
}
|
| 927 |
+
{% endif %}
|
| 928 |
});
|
| 929 |
</script>
|
| 930 |
+
{% endblock %}
|
Backend/fastapi/templates/login.html
CHANGED
|
@@ -9,39 +9,39 @@
|
|
| 9 |
<h2 class="text-3xl font-bold">Admin Login</h2>
|
| 10 |
<p class="mt-2 theme-text-secondary">Sign in to access the admin panel</p>
|
| 11 |
</div>
|
| 12 |
-
|
| 13 |
{% if error %}
|
| 14 |
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
| 15 |
{{ error }}
|
| 16 |
</div>
|
| 17 |
{% endif %}
|
| 18 |
-
|
| 19 |
<form method="post" class="theme-card p-8 rounded-lg shadow-lg space-y-6">
|
| 20 |
<div>
|
| 21 |
<label for="username" class="block text-sm font-medium mb-2">Username</label>
|
| 22 |
-
<input type="text" id="username" name="username" required
|
| 23 |
-
|
| 24 |
</div>
|
| 25 |
-
|
| 26 |
<div>
|
| 27 |
<label for="password" class="block text-sm font-medium mb-2">Password</label>
|
| 28 |
-
<input type="password" id="password" name="password" required
|
| 29 |
-
|
| 30 |
</div>
|
| 31 |
-
|
| 32 |
-
<button type="submit"
|
| 33 |
-
|
| 34 |
Sign In
|
| 35 |
</button>
|
| 36 |
</form>
|
| 37 |
-
|
| 38 |
<div class="text-center">
|
| 39 |
<p class="theme-text-secondary text-sm">
|
| 40 |
-
Public pages:
|
| 41 |
-
<a href="/status" class="text-primary hover:underline">System Status</a> |
|
| 42 |
<a href="/stremio" class="text-primary hover:underline">Stremio Guide</a>
|
| 43 |
</p>
|
| 44 |
</div>
|
| 45 |
</div>
|
| 46 |
</div>
|
| 47 |
-
{% endblock %}
|
|
|
|
| 9 |
<h2 class="text-3xl font-bold">Admin Login</h2>
|
| 10 |
<p class="mt-2 theme-text-secondary">Sign in to access the admin panel</p>
|
| 11 |
</div>
|
| 12 |
+
|
| 13 |
{% if error %}
|
| 14 |
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
| 15 |
{{ error }}
|
| 16 |
</div>
|
| 17 |
{% endif %}
|
| 18 |
+
|
| 19 |
<form method="post" class="theme-card p-8 rounded-lg shadow-lg space-y-6">
|
| 20 |
<div>
|
| 21 |
<label for="username" class="block text-sm font-medium mb-2">Username</label>
|
| 22 |
+
<input type="text" id="username" name="username" required
|
| 23 |
+
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent">
|
| 24 |
</div>
|
| 25 |
+
|
| 26 |
<div>
|
| 27 |
<label for="password" class="block text-sm font-medium mb-2">Password</label>
|
| 28 |
+
<input type="password" id="password" name="password" required
|
| 29 |
+
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent">
|
| 30 |
</div>
|
| 31 |
+
|
| 32 |
+
<button type="submit"
|
| 33 |
+
class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md transition-colors font-medium shadow-sm">
|
| 34 |
Sign In
|
| 35 |
</button>
|
| 36 |
</form>
|
| 37 |
+
|
| 38 |
<div class="text-center">
|
| 39 |
<p class="theme-text-secondary text-sm">
|
| 40 |
+
Public pages:
|
| 41 |
+
<a href="/status" class="text-primary hover:underline">System Status</a> |
|
| 42 |
<a href="/stremio" class="text-primary hover:underline">Stremio Guide</a>
|
| 43 |
</p>
|
| 44 |
</div>
|
| 45 |
</div>
|
| 46 |
</div>
|
| 47 |
+
{% endblock %}
|
Backend/fastapi/templates/media_edit.html
CHANGED
|
@@ -7,30 +7,32 @@
|
|
| 7 |
<!-- Header -->
|
| 8 |
<div class="mb-6 sm:mb-8">
|
| 9 |
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
| 10 |
-
<a href="/media/manage?media_type={{ media_type }}"
|
|
|
|
| 11 |
<svg class="w-4 h-4 sm:w-5 sm:h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 12 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
|
|
| 13 |
</svg>
|
| 14 |
Back to {{ media_type|title }}s
|
| 15 |
</a>
|
| 16 |
</div>
|
| 17 |
<h1 class="text-2xl sm:text-3xl font-bold mt-2">Edit {{ media_type|title }}</h1>
|
| 18 |
-
<p class="theme-text-secondary text-sm sm:text-base mt-1 sm:mt-2">TMDB ID: {{ tmdb_id }} | DB Index: {{ db_index
|
|
|
|
| 19 |
</div>
|
| 20 |
-
|
| 21 |
<!-- Media Preview -->
|
| 22 |
{% if media_details %}
|
| 23 |
<div class="theme-card rounded-lg shadow mb-6 sm:mb-8 overflow-hidden">
|
| 24 |
-
|
| 25 |
<!-- Mobile Layout (Stack vertically) -->
|
| 26 |
<div class="block sm:hidden">
|
| 27 |
<!-- Poster Section -->
|
| 28 |
<div class="bg-gradient-to-br from-gray-800 to-gray-900 p-6">
|
| 29 |
<div class="flex flex-col items-center text-center">
|
| 30 |
-
<img src="{{ media_details.poster or '/static/placeholder.jpg' }}"
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
onerror="this.src='https://via.placeholder.com/300x450/e5e7eb/9ca3af?text=No+Image'">
|
| 34 |
<h2 class="text-xl font-bold text-white mb-2">{{ media_details.title }}</h2>
|
| 35 |
<div class="flex flex-wrap justify-center items-center gap-2 mb-3">
|
| 36 |
{% if media_details.rating %}
|
|
@@ -38,7 +40,8 @@
|
|
| 38 |
β {{ media_details.rating }}
|
| 39 |
</span>
|
| 40 |
{% endif %}
|
| 41 |
-
<span class="text-white text-sm bg-white/20 px-2 py-1 rounded">{{ media_details.release_year
|
|
|
|
| 42 |
</div>
|
| 43 |
<div class="flex flex-wrap justify-center gap-1">
|
| 44 |
{% for genre in media_details.genres[:4] %}
|
|
@@ -51,30 +54,26 @@
|
|
| 51 |
<!-- Backdrop Section -->
|
| 52 |
{% if media_details.backdrop %}
|
| 53 |
<div class="relative h-48 overflow-hidden">
|
| 54 |
-
<img src="{{ media_details.backdrop }}"
|
| 55 |
-
|
| 56 |
-
class="w-full h-full object-cover"
|
| 57 |
-
onerror="this.style.display='none'">
|
| 58 |
<div class="absolute inset-0 bg-black/30"></div>
|
| 59 |
</div>
|
| 60 |
{% endif %}
|
| 61 |
</div>
|
| 62 |
-
|
| 63 |
<!-- Desktop Layout (Original backdrop style) -->
|
| 64 |
<div class="hidden sm:block">
|
| 65 |
{% if media_details.backdrop %}
|
| 66 |
<div class="relative h-64 md:h-80 bg-gradient-to-r from-gray-900 to-gray-700">
|
| 67 |
-
<img src="{{ media_details.backdrop }}"
|
| 68 |
-
|
| 69 |
-
class="w-full h-full object-cover opacity-60"
|
| 70 |
-
onerror="this.style.display='none'">
|
| 71 |
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent"></div>
|
| 72 |
<div class="absolute bottom-0 left-0 right-0 p-6">
|
| 73 |
<div class="flex items-end gap-6">
|
| 74 |
-
<img src="{{ media_details.poster or '/static/placeholder.jpg' }}"
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
<div class="text-white flex-1">
|
| 79 |
<h2 class="text-2xl md:text-4xl font-bold mb-2">{{ media_details.title }}</h2>
|
| 80 |
<div class="flex flex-wrap items-center gap-4 mb-4">
|
|
@@ -88,7 +87,8 @@
|
|
| 88 |
</div>
|
| 89 |
<div class="flex flex-wrap gap-2">
|
| 90 |
{% for genre in media_details.genres[:6] %}
|
| 91 |
-
<span class="bg-white/20 backdrop-blur-sm text-white px-3 py-1 rounded-full text-sm">{{
|
|
|
|
| 92 |
{% endfor %}
|
| 93 |
</div>
|
| 94 |
</div>
|
|
@@ -100,14 +100,14 @@
|
|
| 100 |
<div class="p-6">
|
| 101 |
<div class="flex items-start gap-6">
|
| 102 |
<div class="flex-shrink-0">
|
| 103 |
-
<img src="{{ media_details.poster or '/static/placeholder.jpg' }}"
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
onerror="this.src='https://via.placeholder.com/300x450/e5e7eb/9ca3af?text=No+Image'">
|
| 107 |
</div>
|
| 108 |
<div class="flex-1">
|
| 109 |
<h2 class="text-2xl font-bold mb-2">{{ media_details.title }}</h2>
|
| 110 |
-
<p class="theme-text-secondary mb-4">{{ media_details.release_year }} β’ Rating: {{
|
|
|
|
| 111 |
<div class="flex flex-wrap gap-2 mb-4">
|
| 112 |
{% for genre in media_details.genres[:6] %}
|
| 113 |
<span class="bg-gray-200 text-gray-700 px-2 py-1 rounded-full text-sm">{{ genre }}</span>
|
|
@@ -118,7 +118,7 @@
|
|
| 118 |
</div>
|
| 119 |
{% endif %}
|
| 120 |
</div>
|
| 121 |
-
|
| 122 |
<!-- Description (common for both layouts) -->
|
| 123 |
{% if media_details.description %}
|
| 124 |
<div class="px-4 sm:px-6 pb-4 sm:pb-6">
|
|
@@ -128,7 +128,7 @@
|
|
| 128 |
{% endif %}
|
| 129 |
</div>
|
| 130 |
{% endif %}
|
| 131 |
-
|
| 132 |
<!-- Edit Form -->
|
| 133 |
<div class="theme-card p-4 sm:p-6 rounded-lg shadow mb-6 sm:mb-8">
|
| 134 |
<h3 class="text-lg sm:text-xl font-semibold mb-4 sm:mb-6">Edit Details</h3>
|
|
@@ -136,119 +136,139 @@
|
|
| 136 |
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6 mb-6">
|
| 137 |
<div class="sm:col-span-2">
|
| 138 |
<label class="block text-sm font-medium mb-2">Title *</label>
|
| 139 |
-
<input type="text" id="title" name="title"
|
| 140 |
-
|
| 141 |
-
|
|
|
|
| 142 |
</div>
|
| 143 |
<div>
|
| 144 |
<label class="block text-sm font-medium mb-2">Release Year *</label>
|
| 145 |
-
<input type="number" id="release_year" name="release_year"
|
| 146 |
-
|
| 147 |
-
|
|
|
|
| 148 |
</div>
|
| 149 |
<div>
|
| 150 |
<label class="block text-sm font-medium mb-2">Rating (0-10)</label>
|
| 151 |
-
<input type="number" step="0.1" min="0" max="10" id="rating" name="rating"
|
| 152 |
-
|
| 153 |
-
|
| 154 |
</div>
|
| 155 |
<div class="sm:col-span-2">
|
| 156 |
<label class="block text-sm font-medium mb-2">Logo URL</label>
|
| 157 |
-
<input type="url" id="logo" name="logo"
|
| 158 |
-
|
| 159 |
-
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary">
|
| 160 |
</div>
|
| 161 |
-
|
| 162 |
<div class="sm:col-span-2">
|
| 163 |
<label class="block text-sm font-medium mb-2">Poster URL</label>
|
| 164 |
-
<input type="url" id="poster" name="poster"
|
| 165 |
-
|
| 166 |
-
|
| 167 |
</div>
|
| 168 |
<div class="sm:col-span-2">
|
| 169 |
<label class="block text-sm font-medium mb-2">Backdrop URL</label>
|
| 170 |
-
<input type="url" id="backdrop" name="backdrop"
|
| 171 |
-
|
| 172 |
-
|
| 173 |
</div>
|
| 174 |
</div>
|
| 175 |
-
|
| 176 |
<div class="mb-6">
|
| 177 |
<label class="block text-sm font-medium mb-2">Description</label>
|
| 178 |
-
<textarea id="description" name="description" rows="3"
|
| 179 |
-
|
| 180 |
</div>
|
| 181 |
-
|
| 182 |
<div class="mb-6">
|
| 183 |
<label class="block text-sm font-medium mb-2">Genres (comma-separated)</label>
|
| 184 |
-
<input type="text" id="genres" name="genres"
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
</div>
|
| 189 |
-
|
| 190 |
<div class="flex flex-col sm:flex-row justify-end gap-2 sm:gap-4">
|
| 191 |
-
<button type="button" onclick="window.history.back()"
|
| 192 |
-
|
| 193 |
Cancel
|
| 194 |
</button>
|
| 195 |
-
<button type="submit"
|
| 196 |
-
|
| 197 |
Save Changes
|
| 198 |
</button>
|
| 199 |
</div>
|
| 200 |
</form>
|
| 201 |
</div>
|
| 202 |
-
|
| 203 |
<!-- Quality Management -->
|
| 204 |
{% if media_details %}
|
| 205 |
<div class="theme-card p-4 sm:p-6 rounded-lg shadow">
|
| 206 |
<h3 class="text-lg sm:text-xl font-semibold mb-4 sm:mb-6">Available Content</h3>
|
| 207 |
-
|
| 208 |
{% if media_type == 'movie' and media_details.telegram %}
|
| 209 |
<!-- Movie Qualities (sorted by quality) -->
|
| 210 |
<div class="space-y-3">
|
| 211 |
{% set sorted_qualities = media_details.telegram|sort(attribute='quality') %}
|
| 212 |
{% for quality in sorted_qualities %}
|
| 213 |
-
<div
|
|
|
|
| 214 |
<div class="flex-1">
|
| 215 |
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
| 216 |
-
<span
|
|
|
|
| 217 |
{{ quality.quality }}
|
| 218 |
</span>
|
| 219 |
<span class="text-sm text-gray-600">{{ quality.size }}</span>
|
| 220 |
</div>
|
| 221 |
<p class="text-sm text-gray-500 mt-2 sm:mt-1 break-all">{{ quality.name }}</p>
|
| 222 |
</div>
|
| 223 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
class="self-start sm:self-center text-red-600 hover:text-red-800 hover:bg-red-50 px-3 py-1 rounded transition-colors text-sm">
|
| 225 |
-
|
| 226 |
-
|
|
|
|
| 227 |
</div>
|
| 228 |
{% endfor %}
|
| 229 |
</div>
|
| 230 |
-
|
| 231 |
{% elif media_type == 'tv' and media_details.seasons %}
|
| 232 |
<!-- TV Show Seasons and Episodes (sorted) -->
|
| 233 |
<div class="space-y-4 sm:space-y-6">
|
| 234 |
{% for season in media_details.seasons|sort(attribute='season_number') %}
|
| 235 |
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
| 236 |
<!-- Season Header -->
|
| 237 |
-
<div
|
|
|
|
| 238 |
<div class="flex items-center justify-between">
|
| 239 |
-
<h4 class="text-base sm:text-lg font-semibold text-gray-900">Season {{ season.season_number }}
|
|
|
|
| 240 |
<div class="flex items-center gap-2 sm:gap-4">
|
| 241 |
-
<span class="text-xs sm:text-sm text-gray-600">{{ season.episodes|length }} episode{{ 's' if
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
</svg>
|
| 247 |
</button>
|
| 248 |
</div>
|
| 249 |
</div>
|
| 250 |
</div>
|
| 251 |
-
|
| 252 |
<!-- Episodes List (sorted by episode number) -->
|
| 253 |
<div id="season-content-{{ loop.index0 }}" class="season-content">
|
| 254 |
<div class="divide-y divide-gray-100">
|
|
@@ -256,31 +276,49 @@
|
|
| 256 |
<div class="p-3 sm:p-4 hover:bg-gray-50 transition-colors">
|
| 257 |
<div class="flex flex-col sm:flex-row sm:items-start gap-3 sm:gap-4">
|
| 258 |
<!-- Episode Number -->
|
| 259 |
-
<div
|
|
|
|
| 260 |
<span class="text-sm font-bold text-blue-800">{{ episode.episode_number }}</span>
|
| 261 |
</div>
|
| 262 |
-
|
| 263 |
<!-- Episode Info -->
|
| 264 |
<div class="flex-1 min-w-0">
|
| 265 |
-
<h5 class="text-sm font-medium text-gray-900 mb-2 text-center sm:text-left">{{
|
| 266 |
-
|
|
|
|
| 267 |
<!-- Episode Qualities (sorted by quality) -->
|
| 268 |
{% if episode.telegram %}
|
| 269 |
<div class="space-y-2">
|
| 270 |
{% set sorted_episode_qualities = episode.telegram|sort(attribute='quality') %}
|
| 271 |
{% for quality in sorted_episode_qualities %}
|
| 272 |
-
<div
|
|
|
|
| 273 |
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
|
| 274 |
-
<span
|
|
|
|
| 275 |
{{ quality.quality }}
|
| 276 |
</span>
|
| 277 |
<span class="text-xs text-gray-500">{{ quality.size }}</span>
|
| 278 |
<span class="text-xs text-gray-400 break-all">{{ quality.name }}</span>
|
| 279 |
</div>
|
| 280 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
class="self-start sm:self-center text-red-600 hover:text-red-800 hover:bg-red-50 px-2 py-1 rounded transition-colors text-xs">
|
| 282 |
-
|
| 283 |
-
|
|
|
|
| 284 |
</div>
|
| 285 |
{% endfor %}
|
| 286 |
</div>
|
|
@@ -288,11 +326,12 @@
|
|
| 288 |
<p class="text-xs text-gray-400 text-center sm:text-left">No qualities available</p>
|
| 289 |
{% endif %}
|
| 290 |
</div>
|
| 291 |
-
|
| 292 |
<!-- Episode Actions -->
|
| 293 |
<div class="flex-shrink-0 flex justify-center sm:justify-start">
|
| 294 |
-
<button
|
| 295 |
-
|
|
|
|
| 296 |
Delete Episode
|
| 297 |
</button>
|
| 298 |
</div>
|
|
@@ -300,11 +339,11 @@
|
|
| 300 |
</div>
|
| 301 |
{% endfor %}
|
| 302 |
</div>
|
| 303 |
-
|
| 304 |
<!-- Season Actions -->
|
| 305 |
<div class="px-3 sm:px-4 py-3 bg-gray-50 border-t border-gray-200 text-center sm:text-left">
|
| 306 |
-
<button onclick="deleteTVSeason({{ season.season_number }})"
|
| 307 |
-
|
| 308 |
Delete Entire Season
|
| 309 |
</button>
|
| 310 |
</div>
|
|
@@ -315,7 +354,8 @@
|
|
| 315 |
{% else %}
|
| 316 |
<div class="text-center py-8">
|
| 317 |
<svg class="mx-auto h-12 w-12 text-gray-300 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 318 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
|
|
| 319 |
</svg>
|
| 320 |
<p class="theme-text-secondary text-sm sm:text-base">No content available</p>
|
| 321 |
</div>
|
|
@@ -325,209 +365,584 @@
|
|
| 325 |
</div>
|
| 326 |
|
| 327 |
<script>
|
| 328 |
-
const tmdbId = {{ tmdb_id }};
|
| 329 |
-
const dbIndex = {{ db_index }};
|
| 330 |
-
const mediaType = '{{ media_type }}';
|
| 331 |
-
|
| 332 |
-
// Toggle season visibility
|
| 333 |
-
function toggleSeason(seasonIndex) {
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
} else {
|
| 341 |
-
content.style.display = 'none';
|
| 342 |
-
arrow.style.transform = 'rotate(-90deg)';
|
| 343 |
-
}
|
| 344 |
-
}
|
| 345 |
-
|
| 346 |
-
// Update media
|
| 347 |
-
async function updateMedia(event) {
|
| 348 |
-
event.preventDefault();
|
| 349 |
-
|
| 350 |
-
const formData = new FormData(event.target);
|
| 351 |
-
const updateData = {};
|
| 352 |
-
|
| 353 |
-
// Convert form data to object
|
| 354 |
-
for (let [key, value] of formData.entries()) {
|
| 355 |
-
if (key === 'genres') {
|
| 356 |
-
updateData[key] = value.split(',').map(g => g.trim()).filter(g => g);
|
| 357 |
-
} else if (key === 'rating' || key === 'release_year' || key === 'runtime') {
|
| 358 |
-
updateData[key] = value ? parseFloat(value) : null;
|
| 359 |
} else {
|
| 360 |
-
|
|
|
|
| 361 |
}
|
| 362 |
}
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
}
|
| 380 |
-
} catch (error) {
|
| 381 |
-
console.error('Error updating media:', error);
|
| 382 |
-
showErrorMessage('Error updating media. Please try again.');
|
| 383 |
}
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
|
|
|
|
|
|
|
|
|
| 401 |
}
|
| 402 |
-
} catch (error) {
|
| 403 |
-
console.error('Error deleting :', error);
|
| 404 |
-
showErrorMessage('Error deleting . Please try again.');
|
| 405 |
}
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
|
|
|
|
|
|
|
|
|
| 423 |
}
|
| 424 |
-
} catch (error) {
|
| 425 |
-
console.error('Error deleting :', error);
|
| 426 |
-
showErrorMessage('Error deleting. Please try again.');
|
| 427 |
}
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
|
|
|
|
|
|
|
|
|
| 445 |
}
|
| 446 |
-
} catch (error) {
|
| 447 |
-
console.error('Error deleting episode:', error);
|
| 448 |
-
showErrorMessage('Error deleting episode. Please try again.');
|
| 449 |
}
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
|
|
|
|
|
|
|
|
|
| 467 |
}
|
| 468 |
-
} catch (error) {
|
| 469 |
-
console.error('Error deleting season:', error);
|
| 470 |
-
showErrorMessage('Error deleting season. Please try again.');
|
| 471 |
}
|
| 472 |
-
}
|
| 473 |
|
| 474 |
-
// Show success message
|
| 475 |
-
function showSuccessMessage(message) {
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
<svg class="w-4 h-4 sm:w-5 sm:h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 480 |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
| 481 |
</svg>
|
| 482 |
<span class="text-sm">${message}</span>
|
| 483 |
`;
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
}
|
| 491 |
-
|
| 492 |
-
// Show error message
|
| 493 |
-
function showErrorMessage(message) {
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
<svg class="w-4 h-4 sm:w-5 sm:h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 498 |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
| 499 |
</svg>
|
| 500 |
<span class="text-sm">${message}</span>
|
| 501 |
`;
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
}
|
| 509 |
-
|
| 510 |
-
// Initialize page
|
| 511 |
-
document.addEventListener('DOMContentLoaded', () => {
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
|
|
|
| 518 |
});
|
| 519 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
</script>
|
| 521 |
|
| 522 |
<style>
|
| 523 |
-
.season-content {
|
| 524 |
-
|
| 525 |
-
}
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
</style>
|
| 533 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
<!-- Header -->
|
| 8 |
<div class="mb-6 sm:mb-8">
|
| 9 |
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
| 10 |
+
<a href="/media/manage?media_type={{ media_type }}"
|
| 11 |
+
class="text-primary hover:underline flex items-center text-sm sm:text-base">
|
| 12 |
<svg class="w-4 h-4 sm:w-5 sm:h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 13 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
| 14 |
+
d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
| 15 |
</svg>
|
| 16 |
Back to {{ media_type|title }}s
|
| 17 |
</a>
|
| 18 |
</div>
|
| 19 |
<h1 class="text-2xl sm:text-3xl font-bold mt-2">Edit {{ media_type|title }}</h1>
|
| 20 |
+
<p class="theme-text-secondary text-sm sm:text-base mt-1 sm:mt-2">TMDB ID: {{ tmdb_id }} | DB Index: {{ db_index
|
| 21 |
+
}}</p>
|
| 22 |
</div>
|
| 23 |
+
|
| 24 |
<!-- Media Preview -->
|
| 25 |
{% if media_details %}
|
| 26 |
<div class="theme-card rounded-lg shadow mb-6 sm:mb-8 overflow-hidden">
|
| 27 |
+
|
| 28 |
<!-- Mobile Layout (Stack vertically) -->
|
| 29 |
<div class="block sm:hidden">
|
| 30 |
<!-- Poster Section -->
|
| 31 |
<div class="bg-gradient-to-br from-gray-800 to-gray-900 p-6">
|
| 32 |
<div class="flex flex-col items-center text-center">
|
| 33 |
+
<img src="{{ media_details.poster or '/static/placeholder.jpg' }}" alt="{{ media_details.title }}"
|
| 34 |
+
class="w-32 h-48 object-cover rounded-lg shadow-xl border-4 border-white mb-4"
|
| 35 |
+
onerror="this.src='https://via.placeholder.com/300x450/e5e7eb/9ca3af?text=No+Image'">
|
|
|
|
| 36 |
<h2 class="text-xl font-bold text-white mb-2">{{ media_details.title }}</h2>
|
| 37 |
<div class="flex flex-wrap justify-center items-center gap-2 mb-3">
|
| 38 |
{% if media_details.rating %}
|
|
|
|
| 40 |
β {{ media_details.rating }}
|
| 41 |
</span>
|
| 42 |
{% endif %}
|
| 43 |
+
<span class="text-white text-sm bg-white/20 px-2 py-1 rounded">{{ media_details.release_year
|
| 44 |
+
}}</span>
|
| 45 |
</div>
|
| 46 |
<div class="flex flex-wrap justify-center gap-1">
|
| 47 |
{% for genre in media_details.genres[:4] %}
|
|
|
|
| 54 |
<!-- Backdrop Section -->
|
| 55 |
{% if media_details.backdrop %}
|
| 56 |
<div class="relative h-48 overflow-hidden">
|
| 57 |
+
<img src="{{ media_details.backdrop }}" alt="{{ media_details.title }} backdrop"
|
| 58 |
+
class="w-full h-full object-cover" onerror="this.style.display='none'">
|
|
|
|
|
|
|
| 59 |
<div class="absolute inset-0 bg-black/30"></div>
|
| 60 |
</div>
|
| 61 |
{% endif %}
|
| 62 |
</div>
|
| 63 |
+
|
| 64 |
<!-- Desktop Layout (Original backdrop style) -->
|
| 65 |
<div class="hidden sm:block">
|
| 66 |
{% if media_details.backdrop %}
|
| 67 |
<div class="relative h-64 md:h-80 bg-gradient-to-r from-gray-900 to-gray-700">
|
| 68 |
+
<img src="{{ media_details.backdrop }}" alt="{{ media_details.title }} backdrop"
|
| 69 |
+
class="w-full h-full object-cover opacity-60" onerror="this.style.display='none'">
|
|
|
|
|
|
|
| 70 |
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent"></div>
|
| 71 |
<div class="absolute bottom-0 left-0 right-0 p-6">
|
| 72 |
<div class="flex items-end gap-6">
|
| 73 |
+
<img src="{{ media_details.poster or '/static/placeholder.jpg' }}"
|
| 74 |
+
alt="{{ media_details.title }}"
|
| 75 |
+
class="w-32 md:w-48 h-48 md:h-72 object-cover rounded-lg shadow-2xl border-4 border-white"
|
| 76 |
+
onerror="this.src='https://via.placeholder.com/300x450/e5e7eb/9ca3af?text=No+Image'">
|
| 77 |
<div class="text-white flex-1">
|
| 78 |
<h2 class="text-2xl md:text-4xl font-bold mb-2">{{ media_details.title }}</h2>
|
| 79 |
<div class="flex flex-wrap items-center gap-4 mb-4">
|
|
|
|
| 87 |
</div>
|
| 88 |
<div class="flex flex-wrap gap-2">
|
| 89 |
{% for genre in media_details.genres[:6] %}
|
| 90 |
+
<span class="bg-white/20 backdrop-blur-sm text-white px-3 py-1 rounded-full text-sm">{{
|
| 91 |
+
genre }}</span>
|
| 92 |
{% endfor %}
|
| 93 |
</div>
|
| 94 |
</div>
|
|
|
|
| 100 |
<div class="p-6">
|
| 101 |
<div class="flex items-start gap-6">
|
| 102 |
<div class="flex-shrink-0">
|
| 103 |
+
<img src="{{ media_details.poster or '/static/placeholder.jpg' }}"
|
| 104 |
+
alt="{{ media_details.title }}" class="w-48 h-72 object-cover rounded-lg shadow-lg"
|
| 105 |
+
onerror="this.src='https://via.placeholder.com/300x450/e5e7eb/9ca3af?text=No+Image'">
|
|
|
|
| 106 |
</div>
|
| 107 |
<div class="flex-1">
|
| 108 |
<h2 class="text-2xl font-bold mb-2">{{ media_details.title }}</h2>
|
| 109 |
+
<p class="theme-text-secondary mb-4">{{ media_details.release_year }} β’ Rating: {{
|
| 110 |
+
media_details.rating or 'N/A' }}</p>
|
| 111 |
<div class="flex flex-wrap gap-2 mb-4">
|
| 112 |
{% for genre in media_details.genres[:6] %}
|
| 113 |
<span class="bg-gray-200 text-gray-700 px-2 py-1 rounded-full text-sm">{{ genre }}</span>
|
|
|
|
| 118 |
</div>
|
| 119 |
{% endif %}
|
| 120 |
</div>
|
| 121 |
+
|
| 122 |
<!-- Description (common for both layouts) -->
|
| 123 |
{% if media_details.description %}
|
| 124 |
<div class="px-4 sm:px-6 pb-4 sm:pb-6">
|
|
|
|
| 128 |
{% endif %}
|
| 129 |
</div>
|
| 130 |
{% endif %}
|
| 131 |
+
|
| 132 |
<!-- Edit Form -->
|
| 133 |
<div class="theme-card p-4 sm:p-6 rounded-lg shadow mb-6 sm:mb-8">
|
| 134 |
<h3 class="text-lg sm:text-xl font-semibold mb-4 sm:mb-6">Edit Details</h3>
|
|
|
|
| 136 |
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6 mb-6">
|
| 137 |
<div class="sm:col-span-2">
|
| 138 |
<label class="block text-sm font-medium mb-2">Title *</label>
|
| 139 |
+
<input type="text" id="title" name="title"
|
| 140 |
+
value="{{ media_details.title if media_details else '' }}"
|
| 141 |
+
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
| 142 |
+
required>
|
| 143 |
</div>
|
| 144 |
<div>
|
| 145 |
<label class="block text-sm font-medium mb-2">Release Year *</label>
|
| 146 |
+
<input type="number" id="release_year" name="release_year"
|
| 147 |
+
value="{{ media_details.release_year if media_details else '' }}"
|
| 148 |
+
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
| 149 |
+
required>
|
| 150 |
</div>
|
| 151 |
<div>
|
| 152 |
<label class="block text-sm font-medium mb-2">Rating (0-10)</label>
|
| 153 |
+
<input type="number" step="0.1" min="0" max="10" id="rating" name="rating"
|
| 154 |
+
value="{{ media_details.rating if media_details else '' }}"
|
| 155 |
+
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary">
|
| 156 |
</div>
|
| 157 |
<div class="sm:col-span-2">
|
| 158 |
<label class="block text-sm font-medium mb-2">Logo URL</label>
|
| 159 |
+
<input type="url" id="logo" name="logo" value="{{ media_details.logo if media_details else '' }}"
|
| 160 |
+
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary">
|
|
|
|
| 161 |
</div>
|
| 162 |
+
|
| 163 |
<div class="sm:col-span-2">
|
| 164 |
<label class="block text-sm font-medium mb-2">Poster URL</label>
|
| 165 |
+
<input type="url" id="poster" name="poster"
|
| 166 |
+
value="{{ media_details.poster if media_details else '' }}"
|
| 167 |
+
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary">
|
| 168 |
</div>
|
| 169 |
<div class="sm:col-span-2">
|
| 170 |
<label class="block text-sm font-medium mb-2">Backdrop URL</label>
|
| 171 |
+
<input type="url" id="backdrop" name="backdrop"
|
| 172 |
+
value="{{ media_details.backdrop if media_details else '' }}"
|
| 173 |
+
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary">
|
| 174 |
</div>
|
| 175 |
</div>
|
| 176 |
+
|
| 177 |
<div class="mb-6">
|
| 178 |
<label class="block text-sm font-medium mb-2">Description</label>
|
| 179 |
+
<textarea id="description" name="description" rows="3"
|
| 180 |
+
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary">{{ media_details.description if media_details else '' }}</textarea>
|
| 181 |
</div>
|
| 182 |
+
|
| 183 |
<div class="mb-6">
|
| 184 |
<label class="block text-sm font-medium mb-2">Genres (comma-separated)</label>
|
| 185 |
+
<input type="text" id="genres" name="genres"
|
| 186 |
+
value="{% if media_details and media_details.genres %}{{ media_details.genres|join(', ') }}{% endif %}"
|
| 187 |
+
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
| 188 |
+
placeholder="Action, Comedy, Drama">
|
| 189 |
</div>
|
| 190 |
+
|
| 191 |
<div class="flex flex-col sm:flex-row justify-end gap-2 sm:gap-4">
|
| 192 |
+
<button type="button" onclick="window.history.back()"
|
| 193 |
+
class="w-full sm:w-auto px-6 py-2 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors text-sm">
|
| 194 |
Cancel
|
| 195 |
</button>
|
| 196 |
+
<button type="submit"
|
| 197 |
+
class="w-full sm:w-auto bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md transition-colors text-sm shadow-sm">
|
| 198 |
Save Changes
|
| 199 |
</button>
|
| 200 |
</div>
|
| 201 |
</form>
|
| 202 |
</div>
|
| 203 |
+
|
| 204 |
<!-- Quality Management -->
|
| 205 |
{% if media_details %}
|
| 206 |
<div class="theme-card p-4 sm:p-6 rounded-lg shadow">
|
| 207 |
<h3 class="text-lg sm:text-xl font-semibold mb-4 sm:mb-6">Available Content</h3>
|
| 208 |
+
|
| 209 |
{% if media_type == 'movie' and media_details.telegram %}
|
| 210 |
<!-- Movie Qualities (sorted by quality) -->
|
| 211 |
<div class="space-y-3">
|
| 212 |
{% set sorted_qualities = media_details.telegram|sort(attribute='quality') %}
|
| 213 |
{% for quality in sorted_qualities %}
|
| 214 |
+
<div
|
| 215 |
+
class="flex flex-col sm:flex-row sm:items-center justify-between p-3 sm:p-4 bg-gray-50 rounded-lg border gap-3 sm:gap-0">
|
| 216 |
<div class="flex-1">
|
| 217 |
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
| 218 |
+
<span
|
| 219 |
+
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800 w-fit">
|
| 220 |
{{ quality.quality }}
|
| 221 |
</span>
|
| 222 |
<span class="text-sm text-gray-600">{{ quality.size }}</span>
|
| 223 |
</div>
|
| 224 |
<p class="text-sm text-gray-500 mt-2 sm:mt-1 break-all">{{ quality.name }}</p>
|
| 225 |
</div>
|
| 226 |
+
<div class="flex gap-2">
|
| 227 |
+
<button onclick="copyStreamLink('{{ quality.id }}', '{{ quality.name }}')"
|
| 228 |
+
class="self-start sm:self-center text-green-600 hover:text-green-800 hover:bg-green-50 px-3 py-1 rounded transition-colors text-sm"
|
| 229 |
+
title="Copy direct stream link">
|
| 230 |
+
π Copy Link
|
| 231 |
+
</button>
|
| 232 |
+
<button onclick="runSpeedTest('{{ quality.id }}', 'movie', '{{ quality.quality }}')"
|
| 233 |
+
class="self-start sm:self-center text-blue-600 hover:text-blue-800 hover:bg-blue-50 px-3 py-1 rounded transition-colors text-sm">
|
| 234 |
+
π Speed Test
|
| 235 |
+
</button>
|
| 236 |
+
<button onclick="deleteQuality('{{ quality.id }}')"
|
| 237 |
class="self-start sm:self-center text-red-600 hover:text-red-800 hover:bg-red-50 px-3 py-1 rounded transition-colors text-sm">
|
| 238 |
+
Delete
|
| 239 |
+
</button>
|
| 240 |
+
</div>
|
| 241 |
</div>
|
| 242 |
{% endfor %}
|
| 243 |
</div>
|
| 244 |
+
|
| 245 |
{% elif media_type == 'tv' and media_details.seasons %}
|
| 246 |
<!-- TV Show Seasons and Episodes (sorted) -->
|
| 247 |
<div class="space-y-4 sm:space-y-6">
|
| 248 |
{% for season in media_details.seasons|sort(attribute='season_number') %}
|
| 249 |
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
| 250 |
<!-- Season Header -->
|
| 251 |
+
<div
|
| 252 |
+
class="bg-gradient-to-r from-blue-50 to-indigo-50 px-4 sm:px-6 py-3 sm:py-4 border-b border-gray-200">
|
| 253 |
<div class="flex items-center justify-between">
|
| 254 |
+
<h4 class="text-base sm:text-lg font-semibold text-gray-900">Season {{ season.season_number }}
|
| 255 |
+
</h4>
|
| 256 |
<div class="flex items-center gap-2 sm:gap-4">
|
| 257 |
+
<span class="text-xs sm:text-sm text-gray-600">{{ season.episodes|length }} episode{{ 's' if
|
| 258 |
+
season.episodes|length != 1 else '' }}</span>
|
| 259 |
+
<button onclick="toggleSeason({{ loop.index0 }})"
|
| 260 |
+
class="text-blue-600 hover:text-blue-800 transition-colors p-1">
|
| 261 |
+
<svg id="season-arrow-{{ loop.index0 }}"
|
| 262 |
+
class="w-4 h-4 sm:w-5 sm:h-5 transform transition-transform" fill="none"
|
| 263 |
+
stroke="currentColor" viewBox="0 0 24 24">
|
| 264 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
| 265 |
+
d="M19 9l-7 7-7-7"></path>
|
| 266 |
</svg>
|
| 267 |
</button>
|
| 268 |
</div>
|
| 269 |
</div>
|
| 270 |
</div>
|
| 271 |
+
|
| 272 |
<!-- Episodes List (sorted by episode number) -->
|
| 273 |
<div id="season-content-{{ loop.index0 }}" class="season-content">
|
| 274 |
<div class="divide-y divide-gray-100">
|
|
|
|
| 276 |
<div class="p-3 sm:p-4 hover:bg-gray-50 transition-colors">
|
| 277 |
<div class="flex flex-col sm:flex-row sm:items-start gap-3 sm:gap-4">
|
| 278 |
<!-- Episode Number -->
|
| 279 |
+
<div
|
| 280 |
+
class="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 rounded-lg flex items-center justify-center mx-auto sm:mx-0">
|
| 281 |
<span class="text-sm font-bold text-blue-800">{{ episode.episode_number }}</span>
|
| 282 |
</div>
|
| 283 |
+
|
| 284 |
<!-- Episode Info -->
|
| 285 |
<div class="flex-1 min-w-0">
|
| 286 |
+
<h5 class="text-sm font-medium text-gray-900 mb-2 text-center sm:text-left">{{
|
| 287 |
+
episode.title }}</h5>
|
| 288 |
+
|
| 289 |
<!-- Episode Qualities (sorted by quality) -->
|
| 290 |
{% if episode.telegram %}
|
| 291 |
<div class="space-y-2">
|
| 292 |
{% set sorted_episode_qualities = episode.telegram|sort(attribute='quality') %}
|
| 293 |
{% for quality in sorted_episode_qualities %}
|
| 294 |
+
<div
|
| 295 |
+
class="flex flex-col sm:flex-row sm:items-center justify-between bg-white border border-gray-200 rounded-md p-3 gap-2 sm:gap-0">
|
| 296 |
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
|
| 297 |
+
<span
|
| 298 |
+
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 w-fit">
|
| 299 |
{{ quality.quality }}
|
| 300 |
</span>
|
| 301 |
<span class="text-xs text-gray-500">{{ quality.size }}</span>
|
| 302 |
<span class="text-xs text-gray-400 break-all">{{ quality.name }}</span>
|
| 303 |
</div>
|
| 304 |
+
<div class="flex gap-2">
|
| 305 |
+
<button
|
| 306 |
+
onclick="copyStreamLink('{{ quality.id }}', '{{ quality.name }}')"
|
| 307 |
+
class="self-start sm:self-center text-green-600 hover:text-green-800 hover:bg-green-50 px-2 py-1 rounded transition-colors text-xs"
|
| 308 |
+
title="Copy direct stream link">
|
| 309 |
+
π Copy
|
| 310 |
+
</button>
|
| 311 |
+
<button
|
| 312 |
+
onclick="runSpeedTest('{{ quality.id }}', 'tv', '{{ quality.quality }}')"
|
| 313 |
+
class="self-start sm:self-center text-blue-600 hover:text-blue-800 hover:bg-blue-50 px-2 py-1 rounded transition-colors text-xs">
|
| 314 |
+
π Speed Test
|
| 315 |
+
</button>
|
| 316 |
+
<button
|
| 317 |
+
onclick="deleteTVQuality({{ season.season_number }}, {{ episode.episode_number }}, '{{ quality.id }}')"
|
| 318 |
class="self-start sm:self-center text-red-600 hover:text-red-800 hover:bg-red-50 px-2 py-1 rounded transition-colors text-xs">
|
| 319 |
+
Delete
|
| 320 |
+
</button>
|
| 321 |
+
</div>
|
| 322 |
</div>
|
| 323 |
{% endfor %}
|
| 324 |
</div>
|
|
|
|
| 326 |
<p class="text-xs text-gray-400 text-center sm:text-left">No qualities available</p>
|
| 327 |
{% endif %}
|
| 328 |
</div>
|
| 329 |
+
|
| 330 |
<!-- Episode Actions -->
|
| 331 |
<div class="flex-shrink-0 flex justify-center sm:justify-start">
|
| 332 |
+
<button
|
| 333 |
+
onclick="deleteTVEpisode({{ season.season_number }}, {{ episode.episode_number }})"
|
| 334 |
+
class="text-red-600 hover:text-red-800 hover:bg-red-50 px-2 py-1 rounded text-xs transition-colors">
|
| 335 |
Delete Episode
|
| 336 |
</button>
|
| 337 |
</div>
|
|
|
|
| 339 |
</div>
|
| 340 |
{% endfor %}
|
| 341 |
</div>
|
| 342 |
+
|
| 343 |
<!-- Season Actions -->
|
| 344 |
<div class="px-3 sm:px-4 py-3 bg-gray-50 border-t border-gray-200 text-center sm:text-left">
|
| 345 |
+
<button onclick="deleteTVSeason({{ season.season_number }})"
|
| 346 |
+
class="text-red-600 hover:text-red-800 hover:bg-red-100 px-3 py-1 rounded text-sm transition-colors">
|
| 347 |
Delete Entire Season
|
| 348 |
</button>
|
| 349 |
</div>
|
|
|
|
| 354 |
{% else %}
|
| 355 |
<div class="text-center py-8">
|
| 356 |
<svg class="mx-auto h-12 w-12 text-gray-300 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 357 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
| 358 |
+
d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 110 2h-1v12a2 2 0 01-2-2V6H3a1 1 0 110-2h4zM9 6v10h6V6H9z" />
|
| 359 |
</svg>
|
| 360 |
<p class="theme-text-secondary text-sm sm:text-base">No content available</p>
|
| 361 |
</div>
|
|
|
|
| 365 |
</div>
|
| 366 |
|
| 367 |
<script>
|
| 368 |
+
const tmdbId = {{ tmdb_id }};
|
| 369 |
+
const dbIndex = {{ db_index }};
|
| 370 |
+
const mediaType = '{{ media_type }}';
|
| 371 |
+
|
| 372 |
+
// Toggle season visibility
|
| 373 |
+
function toggleSeason(seasonIndex) {
|
| 374 |
+
const content = document.getElementById(`season-content-${seasonIndex}`);
|
| 375 |
+
const arrow = document.getElementById(`season-arrow-${seasonIndex}`);
|
| 376 |
+
|
| 377 |
+
if (content.style.display === 'none') {
|
| 378 |
+
content.style.display = 'block';
|
| 379 |
+
arrow.style.transform = 'rotate(0deg)';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
} else {
|
| 381 |
+
content.style.display = 'none';
|
| 382 |
+
arrow.style.transform = 'rotate(-90deg)';
|
| 383 |
}
|
| 384 |
}
|
| 385 |
+
|
| 386 |
+
// Update media
|
| 387 |
+
async function updateMedia(event) {
|
| 388 |
+
event.preventDefault();
|
| 389 |
+
|
| 390 |
+
const formData = new FormData(event.target);
|
| 391 |
+
const updateData = {};
|
| 392 |
+
|
| 393 |
+
// Convert form data to object
|
| 394 |
+
for (let [key, value] of formData.entries()) {
|
| 395 |
+
if (key === 'genres') {
|
| 396 |
+
updateData[key] = value.split(',').map(g => g.trim()).filter(g => g);
|
| 397 |
+
} else if (key === 'rating' || key === 'release_year' || key === 'runtime') {
|
| 398 |
+
updateData[key] = value ? parseFloat(value) : null;
|
| 399 |
+
} else {
|
| 400 |
+
updateData[key] = value || null;
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
try {
|
| 405 |
+
const response = await fetch(`/api/media/update?tmdb_id=${tmdbId}&db_index=${dbIndex}&media_type=${mediaType}`, {
|
| 406 |
+
method: 'PUT',
|
| 407 |
+
headers: {
|
| 408 |
+
'Content-Type': 'application/json'
|
| 409 |
+
},
|
| 410 |
+
body: JSON.stringify(updateData)
|
| 411 |
+
});
|
| 412 |
+
|
| 413 |
+
if (response.ok) {
|
| 414 |
+
showSuccessMessage('Media updated successfully');
|
| 415 |
+
setTimeout(() => location.reload(), 1000);
|
| 416 |
+
} else {
|
| 417 |
+
const error = await response.json();
|
| 418 |
+
showErrorMessage(`Error updating media: ${error.detail || 'Unknown error'}`);
|
| 419 |
+
}
|
| 420 |
+
} catch (error) {
|
| 421 |
+
console.error('Error updating media:', error);
|
| 422 |
+
showErrorMessage('Error updating media. Please try again.');
|
| 423 |
}
|
|
|
|
|
|
|
|
|
|
| 424 |
}
|
| 425 |
+
|
| 426 |
+
// Delete name from movie
|
| 427 |
+
async function deleteQuality(id) {
|
| 428 |
+
if (!confirm(`Are you sure you want to delete the ${id} ?`)) return;
|
| 429 |
+
|
| 430 |
+
try {
|
| 431 |
+
const response = await fetch(`/api/media/delete-quality?tmdb_id=${tmdbId}&db_index=${dbIndex}&id=${encodeURIComponent(id)}`, {
|
| 432 |
+
method: 'DELETE'
|
| 433 |
+
});
|
| 434 |
+
|
| 435 |
+
if (response.ok) {
|
| 436 |
+
showSuccessMessage(`${id} deleted successfully`);
|
| 437 |
+
setTimeout(() => location.reload(), 1000);
|
| 438 |
+
} else {
|
| 439 |
+
const error = await response.json();
|
| 440 |
+
showErrorMessage(`Error deleting : ${error.detail || 'Unknown error'}`);
|
| 441 |
+
}
|
| 442 |
+
} catch (error) {
|
| 443 |
+
console.error('Error deleting :', error);
|
| 444 |
+
showErrorMessage('Error deleting . Please try again.');
|
| 445 |
}
|
|
|
|
|
|
|
|
|
|
| 446 |
}
|
| 447 |
+
|
| 448 |
+
// Delete TV quality
|
| 449 |
+
async function deleteTVQuality(season, episode, id) {
|
| 450 |
+
if (!confirm(`Are you sure you want to delete the ${id} from Season ${season} Episode ${episode}?`)) return;
|
| 451 |
+
|
| 452 |
+
try {
|
| 453 |
+
const response = await fetch(`/api/media/delete-tv-quality?tmdb_id=${tmdbId}&db_index=${dbIndex}&season=${season}&episode=${episode}&id=${encodeURIComponent(id)}`, {
|
| 454 |
+
method: 'DELETE'
|
| 455 |
+
});
|
| 456 |
+
|
| 457 |
+
if (response.ok) {
|
| 458 |
+
showSuccessMessage(`${id} deleted successfully`);
|
| 459 |
+
setTimeout(() => location.reload(), 1000);
|
| 460 |
+
} else {
|
| 461 |
+
const error = await response.json();
|
| 462 |
+
showErrorMessage(`Error deleting : ${error.detail || 'Unknown error'}`);
|
| 463 |
+
}
|
| 464 |
+
} catch (error) {
|
| 465 |
+
console.error('Error deleting :', error);
|
| 466 |
+
showErrorMessage('Error deleting. Please try again.');
|
| 467 |
}
|
|
|
|
|
|
|
|
|
|
| 468 |
}
|
| 469 |
+
|
| 470 |
+
// Delete TV episode
|
| 471 |
+
async function deleteTVEpisode(season, episode) {
|
| 472 |
+
if (!confirm(`Are you sure you want to delete Season ${season} Episode ${episode}?\n\nThis will remove the entire episode and all its qualities.`)) return;
|
| 473 |
+
|
| 474 |
+
try {
|
| 475 |
+
const response = await fetch(`/api/media/delete-tv-episode?tmdb_id=${tmdbId}&db_index=${dbIndex}&season=${season}&episode=${episode}`, {
|
| 476 |
+
method: 'DELETE'
|
| 477 |
+
});
|
| 478 |
+
|
| 479 |
+
if (response.ok) {
|
| 480 |
+
showSuccessMessage(`Episode ${episode} deleted successfully`);
|
| 481 |
+
setTimeout(() => location.reload(), 1000);
|
| 482 |
+
} else {
|
| 483 |
+
const error = await response.json();
|
| 484 |
+
showErrorMessage(`Error deleting episode: ${error.detail || 'Unknown error'}`);
|
| 485 |
+
}
|
| 486 |
+
} catch (error) {
|
| 487 |
+
console.error('Error deleting episode:', error);
|
| 488 |
+
showErrorMessage('Error deleting episode. Please try again.');
|
| 489 |
}
|
|
|
|
|
|
|
|
|
|
| 490 |
}
|
| 491 |
+
|
| 492 |
+
// Delete TV season
|
| 493 |
+
async function deleteTVSeason(season) {
|
| 494 |
+
if (!confirm(`Are you sure you want to delete the entire Season ${season}?\n\nThis will remove all episodes and qualities in this season. This action cannot be undone.`)) return;
|
| 495 |
+
|
| 496 |
+
try {
|
| 497 |
+
const response = await fetch(`/api/media/delete-tv-season?tmdb_id=${tmdbId}&db_index=${dbIndex}&season=${season}`, {
|
| 498 |
+
method: 'DELETE'
|
| 499 |
+
});
|
| 500 |
+
|
| 501 |
+
if (response.ok) {
|
| 502 |
+
showSuccessMessage(`Season ${season} deleted successfully`);
|
| 503 |
+
setTimeout(() => location.reload(), 1000);
|
| 504 |
+
} else {
|
| 505 |
+
const error = await response.json();
|
| 506 |
+
showErrorMessage(`Error deleting season: ${error.detail || 'Unknown error'}`);
|
| 507 |
+
}
|
| 508 |
+
} catch (error) {
|
| 509 |
+
console.error('Error deleting season:', error);
|
| 510 |
+
showErrorMessage('Error deleting season. Please try again.');
|
| 511 |
}
|
|
|
|
|
|
|
|
|
|
| 512 |
}
|
|
|
|
| 513 |
|
| 514 |
+
// Show success message
|
| 515 |
+
function showSuccessMessage(message) {
|
| 516 |
+
const msg = document.createElement('div');
|
| 517 |
+
msg.className = 'fixed top-4 right-4 bg-green-500 text-white px-4 sm:px-6 py-3 rounded-lg shadow-lg z-50 flex items-center max-w-sm';
|
| 518 |
+
msg.innerHTML = `
|
| 519 |
<svg class="w-4 h-4 sm:w-5 sm:h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 520 |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
| 521 |
</svg>
|
| 522 |
<span class="text-sm">${message}</span>
|
| 523 |
`;
|
| 524 |
+
document.body.appendChild(msg);
|
| 525 |
+
setTimeout(() => {
|
| 526 |
+
if (document.body.contains(msg)) {
|
| 527 |
+
document.body.removeChild(msg);
|
| 528 |
+
}
|
| 529 |
+
}, 4000);
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
// Show error message
|
| 533 |
+
function showErrorMessage(message) {
|
| 534 |
+
const msg = document.createElement('div');
|
| 535 |
+
msg.className = 'fixed top-4 right-4 bg-red-500 text-white px-4 sm:px-6 py-3 rounded-lg shadow-lg z-50 flex items-center max-w-sm';
|
| 536 |
+
msg.innerHTML = `
|
| 537 |
<svg class="w-4 h-4 sm:w-5 sm:h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 538 |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
| 539 |
</svg>
|
| 540 |
<span class="text-sm">${message}</span>
|
| 541 |
`;
|
| 542 |
+
document.body.appendChild(msg);
|
| 543 |
+
setTimeout(() => {
|
| 544 |
+
if (document.body.contains(msg)) {
|
| 545 |
+
document.body.removeChild(msg);
|
| 546 |
+
}
|
| 547 |
+
}, 5000);
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
// Initialize page
|
| 551 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 552 |
+
// Initialize all seasons as collapsed except the first one
|
| 553 |
+
document.querySelectorAll('.season-content').forEach((content, index) => {
|
| 554 |
+
if (index > 0) {
|
| 555 |
+
content.style.display = 'none';
|
| 556 |
+
document.getElementById(`season-arrow-${index}`).style.transform = 'rotate(-90deg)';
|
| 557 |
+
}
|
| 558 |
+
});
|
| 559 |
});
|
| 560 |
+
|
| 561 |
+
// Copy direct stream link to clipboard
|
| 562 |
+
function copyStreamLink(qualityId, fileName) {
|
| 563 |
+
// Try to find token from existing manifest URLs on the dashboard,
|
| 564 |
+
// fall back to prompting the user.
|
| 565 |
+
const base = window.location.origin;
|
| 566 |
+
|
| 567 |
+
// Ask user which token to use (pulled from cookies or prompt)
|
| 568 |
+
const token = getCopylinkToken();
|
| 569 |
+
if (!token) {
|
| 570 |
+
showErrorMessage('No API token found. Please copy the link from the Dashboard page.');
|
| 571 |
+
return;
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
const encodedName = encodeURIComponent(fileName || 'stream');
|
| 575 |
+
const url = `${base}/dl/${token}/${qualityId}/${encodedName}`;
|
| 576 |
+
navigator.clipboard.writeText(url).then(() => {
|
| 577 |
+
showSuccessMessage('Stream link copied to clipboard!');
|
| 578 |
+
}).catch(() => {
|
| 579 |
+
// Fallback: show the URL in a prompt
|
| 580 |
+
prompt('Copy this stream link:', url);
|
| 581 |
+
});
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
function getCopylinkToken() {
|
| 585 |
+
// Check sessionStorage / localStorage for a cached token
|
| 586 |
+
return sessionStorage.getItem('api_token') || localStorage.getItem('api_token') || null;
|
| 587 |
+
}
|
| 588 |
</script>
|
| 589 |
|
| 590 |
<style>
|
| 591 |
+
.season-content {
|
| 592 |
+
transition: all 0.3s ease;
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
.break-all {
|
| 596 |
+
word-break: break-all;
|
| 597 |
+
overflow-wrap: break-word;
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
@keyframes spin {
|
| 601 |
+
to {
|
| 602 |
+
transform: rotate(360deg);
|
| 603 |
+
}
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
.st-spin {
|
| 607 |
+
animation: spin 1s linear infinite;
|
| 608 |
+
}
|
| 609 |
</style>
|
| 610 |
+
|
| 611 |
+
<!-- ===== SPEED TEST MODAL ===== -->
|
| 612 |
+
<div id="speed-test-modal" class="fixed inset-0 z-50 hidden" aria-modal="true" role="dialog">
|
| 613 |
+
<!-- Backdrop -->
|
| 614 |
+
<div class="absolute inset-0 bg-black/60" style="backdrop-filter:blur(4px)" onclick="closeSpeedTest()"></div>
|
| 615 |
+
|
| 616 |
+
<!-- Panel -->
|
| 617 |
+
<div class="absolute left-1/2 top-1/2 w-full max-w-2xl mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden"
|
| 618 |
+
style="transform:translate(-50%,-50%)">
|
| 619 |
+
|
| 620 |
+
<!-- Header -->
|
| 621 |
+
<div
|
| 622 |
+
class="flex items-center justify-between px-6 py-4 border-b border-gray-200 bg-gradient-to-r from-blue-600 to-indigo-600">
|
| 623 |
+
<div>
|
| 624 |
+
<h2 class="text-lg font-bold text-white">π DC Speed Test</h2>
|
| 625 |
+
<p id="st-label" class="text-xs text-blue-100 mt-0.5"></p>
|
| 626 |
+
</div>
|
| 627 |
+
<button onclick="closeSpeedTest()"
|
| 628 |
+
class="text-white/70 hover:text-white text-3xl leading-none transition-colors">×</button>
|
| 629 |
+
</div>
|
| 630 |
+
|
| 631 |
+
<!-- Body -->
|
| 632 |
+
<div class="px-6 py-6 min-h-[200px]">
|
| 633 |
+
<!-- Spinner -->
|
| 634 |
+
<div id="st-spinner" class="flex flex-col items-center gap-4 py-10">
|
| 635 |
+
<div class="w-14 h-14 rounded-full border-4 border-blue-100 border-t-blue-600 st-spin"></div>
|
| 636 |
+
<p class="text-sm text-gray-500">Testing all DC clients simultaneously…</p>
|
| 637 |
+
<p class="text-xs text-gray-400">Downloading 100 MB from each bot client</p>
|
| 638 |
+
</div>
|
| 639 |
+
|
| 640 |
+
<!-- Results table -->
|
| 641 |
+
<div id="st-results" class="hidden">
|
| 642 |
+
<div class="overflow-x-auto rounded-xl border border-gray-200">
|
| 643 |
+
<table class="w-full text-sm">
|
| 644 |
+
<thead class="bg-gray-50 text-left">
|
| 645 |
+
<tr>
|
| 646 |
+
<th class="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wider">Bot /
|
| 647 |
+
DC</th>
|
| 648 |
+
<th class="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wider">Ping
|
| 649 |
+
</th>
|
| 650 |
+
<th class="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wider">Speed
|
| 651 |
+
</th>
|
| 652 |
+
<th class="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wider">Time
|
| 653 |
+
</th>
|
| 654 |
+
<th class="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wider">
|
| 655 |
+
Downloaded</th>
|
| 656 |
+
<th class="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wider">
|
| 657 |
+
Status</th>
|
| 658 |
+
</tr>
|
| 659 |
+
</thead>
|
| 660 |
+
<tbody id="st-tbody" class="divide-y divide-gray-100 bg-white"></tbody>
|
| 661 |
+
</table>
|
| 662 |
+
</div>
|
| 663 |
+
<p id="st-summary" class="text-xs text-gray-400 mt-3 text-right"></p>
|
| 664 |
+
</div>
|
| 665 |
+
|
| 666 |
+
<!-- Error banner -->
|
| 667 |
+
<div id="st-error" class="hidden bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700">
|
| 668 |
+
</div>
|
| 669 |
+
</div>
|
| 670 |
+
|
| 671 |
+
<!-- Footer -->
|
| 672 |
+
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 bg-gray-50">
|
| 673 |
+
<button id="st-rerun-btn" onclick="rerunSpeedTest()"
|
| 674 |
+
class="hidden px-4 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
| 675 |
+
π Re-run
|
| 676 |
+
</button>
|
| 677 |
+
<button onclick="closeSpeedTest()"
|
| 678 |
+
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors">
|
| 679 |
+
Close
|
| 680 |
+
</button>
|
| 681 |
+
</div>
|
| 682 |
+
</div>
|
| 683 |
+
</div>
|
| 684 |
+
|
| 685 |
+
<script>
|
| 686 |
+
// βββ Speed Test JS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 687 |
+
let _stQualityId = null;
|
| 688 |
+
let _stMediaType = null;
|
| 689 |
+
let _stLabel = null;
|
| 690 |
+
|
| 691 |
+
async function runSpeedTest(qualityId, mediaTypeArg, label) {
|
| 692 |
+
_stQualityId = qualityId;
|
| 693 |
+
_stMediaType = mediaTypeArg;
|
| 694 |
+
_stLabel = label;
|
| 695 |
+
_openModal(label);
|
| 696 |
+
await _exec();
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
async function rerunSpeedTest() {
|
| 700 |
+
if (!_stQualityId) return;
|
| 701 |
+
_resetUI(_stLabel);
|
| 702 |
+
await _exec();
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
function _openModal(label) {
|
| 706 |
+
document.getElementById('speed-test-modal').classList.remove('hidden');
|
| 707 |
+
document.body.style.overflow = 'hidden';
|
| 708 |
+
_resetUI(label);
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
function _resetUI(label) {
|
| 712 |
+
document.getElementById('st-label').textContent = label ? `File: ${label}` : '';
|
| 713 |
+
document.getElementById('st-spinner').classList.remove('hidden');
|
| 714 |
+
document.getElementById('st-results').classList.add('hidden');
|
| 715 |
+
document.getElementById('st-error').classList.add('hidden');
|
| 716 |
+
document.getElementById('st-rerun-btn').classList.add('hidden');
|
| 717 |
+
document.getElementById('st-tbody').innerHTML = '';
|
| 718 |
+
document.getElementById('st-summary').textContent = '';
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
function closeSpeedTest() {
|
| 722 |
+
document.getElementById('speed-test-modal').classList.add('hidden');
|
| 723 |
+
document.body.style.overflow = '';
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
function _pingClass(ms) {
|
| 727 |
+
if (ms === null) return 'text-gray-400';
|
| 728 |
+
if (ms < 100) return 'text-green-600 font-semibold';
|
| 729 |
+
if (ms < 300) return 'text-yellow-600 font-semibold';
|
| 730 |
+
return 'text-red-600 font-semibold';
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
function _speedClass(mbps) {
|
| 734 |
+
if (mbps === null) return 'text-gray-400';
|
| 735 |
+
if (mbps >= 20) return 'text-green-600 font-bold';
|
| 736 |
+
if (mbps >= 5) return 'text-yellow-600 font-bold';
|
| 737 |
+
return 'text-red-600 font-bold';
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
function _miniBar(value, max, color) {
|
| 741 |
+
if (!value || !max) return '';
|
| 742 |
+
const pct = Math.min(100, (value / max) * 100).toFixed(1);
|
| 743 |
+
return `<div class="w-full bg-gray-100 rounded-full h-1.5 mt-1">
|
| 744 |
+
<div class="${color} h-1.5 rounded-full transition-all" style="width:${pct}%"></div>
|
| 745 |
+
</div>`;
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
async function _exec() {
|
| 749 |
+
const params = new URLSearchParams({
|
| 750 |
+
quality_id: _stQualityId,
|
| 751 |
+
tmdb_id: tmdbId,
|
| 752 |
+
db_index: dbIndex,
|
| 753 |
+
media_type: mediaType // from template context (movie/tv)
|
| 754 |
+
});
|
| 755 |
+
|
| 756 |
+
// Close any old connection
|
| 757 |
+
if (window._stEventSource) {
|
| 758 |
+
window._stEventSource.close();
|
| 759 |
+
window._stEventSource = null;
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
let maxSpeed = 1;
|
| 763 |
+
let completedCount = 0;
|
| 764 |
+
let totalCount = 0;
|
| 765 |
+
const allResults = [];
|
| 766 |
+
const pendingRows = {};
|
| 767 |
+
|
| 768 |
+
const es = new EventSource(`/api/system/speedtest/stream?${params}`);
|
| 769 |
+
window._stEventSource = es;
|
| 770 |
+
|
| 771 |
+
function updateSummary() {
|
| 772 |
+
const best = allResults.filter(r => r.speed_mbps).sort((a, b) => b.speed_mbps - a.speed_mbps)[0];
|
| 773 |
+
const summaryEl = document.getElementById('st-summary');
|
| 774 |
+
if (best) {
|
| 775 |
+
summaryEl.textContent = `π Fastest: DC ${best.dc_id} β ${best.speed_mbps.toFixed(2)} MB/s | ${completedCount}/${totalCount} client(s) done`;
|
| 776 |
+
} else {
|
| 777 |
+
summaryEl.textContent = `${completedCount}/${totalCount} client(s) done`;
|
| 778 |
+
}
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
es.onmessage = (e) => {
|
| 782 |
+
let msg;
|
| 783 |
+
try { msg = JSON.parse(e.data); } catch { return; }
|
| 784 |
+
|
| 785 |
+
if (msg.type === 'error') {
|
| 786 |
+
es.close();
|
| 787 |
+
document.getElementById('st-spinner').classList.add('hidden');
|
| 788 |
+
document.getElementById('st-error').textContent = `Speed test stream error: ${msg.message}`;
|
| 789 |
+
document.getElementById('st-error').classList.remove('hidden');
|
| 790 |
+
document.getElementById('st-rerun-btn').classList.remove('hidden');
|
| 791 |
+
return;
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
if (msg.type === 'start') {
|
| 795 |
+
totalCount = msg.total;
|
| 796 |
+
document.getElementById('st-spinner').classList.add('hidden');
|
| 797 |
+
document.getElementById('st-results').classList.remove('hidden');
|
| 798 |
+
|
| 799 |
+
if (msg.target_dc && msg.target_dc !== "?") {
|
| 800 |
+
document.getElementById('st-label').innerHTML += ` | Target DC: <span class="text-white font-semibold">${msg.target_dc}</span>`;
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
const tbody = document.getElementById('st-tbody');
|
| 804 |
+
tbody.innerHTML = '';
|
| 805 |
+
for (let i = 0; i < totalCount; i++) {
|
| 806 |
+
const tr = document.createElement('tr');
|
| 807 |
+
tr.className = 'animate-pulse';
|
| 808 |
+
tr.innerHTML = `
|
| 809 |
+
<td class="px-4 py-3 font-medium text-gray-400">π Bot ${i + 1}</td>
|
| 810 |
+
<td class="px-4 py-3"><span class="text-gray-300">testingβ¦</span></td>
|
| 811 |
+
<td class="px-4 py-3"><span class="text-gray-300">testingβ¦</span></td>
|
| 812 |
+
<td class="px-4 py-3 text-gray-300">β</td>
|
| 813 |
+
<td class="px-4 py-3 text-gray-300">β</td>
|
| 814 |
+
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-gray-100 text-gray-400">Pending</span></td>`;
|
| 815 |
+
tbody.appendChild(tr);
|
| 816 |
+
pendingRows[i] = tr;
|
| 817 |
+
}
|
| 818 |
+
updateSummary();
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
if (msg.type === 'progress') {
|
| 822 |
+
const r = msg.data;
|
| 823 |
+
const tr = pendingRows[r.client_index];
|
| 824 |
+
if (tr) {
|
| 825 |
+
maxSpeed = Math.max(maxSpeed, r.speed_mbps || 0, 1);
|
| 826 |
+
|
| 827 |
+
const ping = r.ping_ms !== null ? `<span class="${_pingClass(r.ping_ms)}">${r.ping_ms} ms</span>` : '<span class="text-gray-400">β</span>';
|
| 828 |
+
const speed = r.speed_mbps !== null
|
| 829 |
+
? `<span class="${_speedClass(r.speed_mbps)}">${r.speed_mbps.toFixed(2)} MB/s</span>
|
| 830 |
+
${_miniBar(r.speed_mbps, maxSpeed, r.speed_mbps >= 20 ? 'bg-green-500' : r.speed_mbps >= 5 ? 'bg-yellow-400' : 'bg-red-400')}`
|
| 831 |
+
: '<span class="text-gray-400">β</span>';
|
| 832 |
+
const taken = r.time_taken_sec !== null ? `${r.time_taken_sec.toFixed(2)}s` : 'β';
|
| 833 |
+
const bytes = r.bytes_downloaded ? `${(r.bytes_downloaded / 1048576).toFixed(2)} MB` : 'β';
|
| 834 |
+
|
| 835 |
+
// Update the innerHTML of the pending row cells
|
| 836 |
+
tr.innerHTML = `
|
| 837 |
+
<td class="px-4 py-3 font-medium text-gray-700">π Bot ${r.client_index + 1} <span class="text-xs text-gray-400">(DC ${r.dc_id})</span></td>
|
| 838 |
+
<td class="px-4 py-3">${ping}</td>
|
| 839 |
+
<td class="px-4 py-3">${speed}</td>
|
| 840 |
+
<td class="px-4 py-3 text-gray-600 text-sm">${taken}</td>
|
| 841 |
+
<td class="px-4 py-3 text-gray-500 text-xs">${bytes}</td>
|
| 842 |
+
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-blue-100 text-blue-700 animate-pulse">Runningβ¦</span></td>`;
|
| 843 |
+
}
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
if (msg.type === 'result') {
|
| 847 |
+
completedCount = msg.completed;
|
| 848 |
+
totalCount = msg.total;
|
| 849 |
+
const r = msg.data;
|
| 850 |
+
allResults.push(r);
|
| 851 |
+
|
| 852 |
+
maxSpeed = Math.max(maxSpeed, r.speed_mbps || 0, 1);
|
| 853 |
+
|
| 854 |
+
const isErr = !!r.error && !r.speed_mbps;
|
| 855 |
+
// Currently best logic isn't perfect until all finish, but we try
|
| 856 |
+
const medal = '';
|
| 857 |
+
const dc = `Bot ${r.client_index + 1} <span class="text-xs text-gray-400">(DC ${r.dc_id})</span>`;
|
| 858 |
+
const ping = r.ping_ms !== null ? `<span class="${_pingClass(r.ping_ms)}">${r.ping_ms} ms</span>` : '<span class="text-gray-400">β</span>';
|
| 859 |
+
const speed = r.speed_mbps !== null
|
| 860 |
+
? `<span class="${_speedClass(r.speed_mbps)}">${r.speed_mbps.toFixed(2)} MB/s</span>
|
| 861 |
+
${_miniBar(r.speed_mbps, maxSpeed, r.speed_mbps >= 20 ? 'bg-green-500' : r.speed_mbps >= 5 ? 'bg-yellow-400' : 'bg-red-400')}`
|
| 862 |
+
: '<span class="text-gray-400">β</span>';
|
| 863 |
+
const taken = r.time_taken_sec !== null ? `${r.time_taken_sec.toFixed(2)}s` : 'β';
|
| 864 |
+
const bytes = r.bytes_downloaded ? `${(r.bytes_downloaded / 1048576).toFixed(2)} MB` : 'β';
|
| 865 |
+
const badge = isErr
|
| 866 |
+
? `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">Error</span>`
|
| 867 |
+
: `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">β OK</span>`;
|
| 868 |
+
|
| 869 |
+
const tr = document.createElement('tr');
|
| 870 |
+
tr.className = `hover:bg-blue-50 transition-colors ${isErr ? 'opacity-60' : ''}`;
|
| 871 |
+
tr.innerHTML = `
|
| 872 |
+
<td class="px-4 py-3 font-medium">${medal}${dc}</td>
|
| 873 |
+
<td class="px-4 py-3">${ping}</td>
|
| 874 |
+
<td class="px-4 py-3">${speed}${isErr ? `<div class="text-xs text-red-500 mt-1">${r.error}</div>` : ''}</td>
|
| 875 |
+
<td class="px-4 py-3 text-gray-600 text-sm">${taken}</td>
|
| 876 |
+
<td class="px-4 py-3 text-gray-500 text-xs">${bytes}</td>
|
| 877 |
+
<td class="px-4 py-3">${badge}</td>`;
|
| 878 |
+
|
| 879 |
+
if (pendingRows[r.client_index]) {
|
| 880 |
+
pendingRows[r.client_index].replaceWith(tr);
|
| 881 |
+
delete pendingRows[r.client_index];
|
| 882 |
+
} else {
|
| 883 |
+
document.getElementById('st-tbody').appendChild(tr);
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
updateSummary();
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
if (msg.type === 'done') {
|
| 890 |
+
es.close();
|
| 891 |
+
window._stEventSource = null;
|
| 892 |
+
|
| 893 |
+
// Re-sort everything to put fastest at the top with a medal
|
| 894 |
+
allResults.sort((a, b) => (b.speed_mbps || 0) - (a.speed_mbps || 0));
|
| 895 |
+
|
| 896 |
+
const tbody = document.getElementById('st-tbody');
|
| 897 |
+
tbody.innerHTML = '';
|
| 898 |
+
|
| 899 |
+
for (let i = 0; i < allResults.length; i++) {
|
| 900 |
+
const r = allResults[i];
|
| 901 |
+
const isErr = !!r.error && !r.speed_mbps;
|
| 902 |
+
const medal = i === 0 && !isErr ? 'π₯ ' : '';
|
| 903 |
+
const dc = `Bot ${r.client_index + 1} <span class="text-xs text-gray-400">(DC ${r.dc_id})</span>`;
|
| 904 |
+
const ping = r.ping_ms !== null ? `<span class="${_pingClass(r.ping_ms)}">${r.ping_ms} ms</span>` : '<span class="text-gray-400">β</span>';
|
| 905 |
+
const speed = r.speed_mbps !== null
|
| 906 |
+
? `<span class="${_speedClass(r.speed_mbps)}">${r.speed_mbps.toFixed(2)} MB/s</span>
|
| 907 |
+
${_miniBar(r.speed_mbps, maxSpeed, r.speed_mbps >= 20 ? 'bg-green-500' : r.speed_mbps >= 5 ? 'bg-yellow-400' : 'bg-red-400')}`
|
| 908 |
+
: '<span class="text-gray-400">β</span>';
|
| 909 |
+
const taken = r.time_taken_sec !== null ? `${r.time_taken_sec.toFixed(2)}s` : 'β';
|
| 910 |
+
const bytes = r.bytes_downloaded ? `${(r.bytes_downloaded / 1048576).toFixed(2)} MB` : 'β';
|
| 911 |
+
const badge = isErr
|
| 912 |
+
? `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">Error</span>`
|
| 913 |
+
: `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">β OK</span>`;
|
| 914 |
+
|
| 915 |
+
const tr = document.createElement('tr');
|
| 916 |
+
tr.className = `hover:bg-blue-50 transition-colors ${isErr ? 'opacity-60' : ''}`;
|
| 917 |
+
tr.innerHTML = `
|
| 918 |
+
<td class="px-4 py-3 font-medium">${medal}${dc}</td>
|
| 919 |
+
<td class="px-4 py-3">${ping}</td>
|
| 920 |
+
<td class="px-4 py-3">${speed}${isErr ? `<div class="text-xs text-red-500 mt-1">${r.error}</div>` : ''}</td>
|
| 921 |
+
<td class="px-4 py-3 text-gray-600 text-sm">${taken}</td>
|
| 922 |
+
<td class="px-4 py-3 text-gray-500 text-xs">${bytes}</td>
|
| 923 |
+
<td class="px-4 py-3">${badge}</td>`;
|
| 924 |
+
tbody.appendChild(tr);
|
| 925 |
+
}
|
| 926 |
+
|
| 927 |
+
document.getElementById('st-rerun-btn').classList.remove('hidden');
|
| 928 |
+
updateSummary();
|
| 929 |
+
}
|
| 930 |
+
};
|
| 931 |
+
|
| 932 |
+
es.onerror = (err) => {
|
| 933 |
+
es.close();
|
| 934 |
+
window._stEventSource = null;
|
| 935 |
+
if (completedCount === 0) {
|
| 936 |
+
document.getElementById('st-spinner').classList.add('hidden');
|
| 937 |
+
document.getElementById('st-error').textContent = 'Live tracking connection failed or interrupted.';
|
| 938 |
+
document.getElementById('st-error').classList.remove('hidden');
|
| 939 |
+
}
|
| 940 |
+
document.getElementById('st-rerun-btn').classList.remove('hidden');
|
| 941 |
+
};
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
// Close on Escape key
|
| 945 |
+
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSpeedTest(); });
|
| 946 |
+
</script>
|
| 947 |
+
|
| 948 |
+
{% endblock %}
|
Backend/fastapi/templates/media_management.html
CHANGED
|
@@ -10,72 +10,75 @@
|
|
| 10 |
<h1 class="text-3xl font-bold">Media Management</h1>
|
| 11 |
<p class="theme-text-secondary mt-2">Manage your movie and TV show collections</p>
|
| 12 |
</div>
|
| 13 |
-
|
| 14 |
<div class="flex space-x-2">
|
| 15 |
-
<a href="/media/manage?media_type=movie"
|
| 16 |
-
|
| 17 |
Movies
|
| 18 |
</a>
|
| 19 |
-
<a href="/media/manage?media_type=tv"
|
| 20 |
-
|
| 21 |
TV Shows
|
| 22 |
</a>
|
| 23 |
</div>
|
| 24 |
</div>
|
| 25 |
-
|
| 26 |
<!-- Search and Filters -->
|
| 27 |
<div class="theme-card p-6 rounded-lg shadow mb-8">
|
| 28 |
<div class="flex flex-col md:flex-row gap-4">
|
| 29 |
<div class="flex-1">
|
| 30 |
-
<input type="text" id="search-input" placeholder="Search {{ media_type }}s by title..."
|
| 31 |
-
|
| 32 |
</div>
|
| 33 |
<div class="flex gap-2">
|
| 34 |
-
<button onclick="searchMedia()"
|
| 35 |
-
|
| 36 |
Search
|
| 37 |
</button>
|
| 38 |
-
<button onclick="clearSearch()"
|
| 39 |
-
|
| 40 |
Clear
|
| 41 |
</button>
|
| 42 |
</div>
|
| 43 |
</div>
|
| 44 |
</div>
|
| 45 |
-
|
| 46 |
<!-- Loading Indicator -->
|
| 47 |
<div id="loading" class="text-center py-12 hidden">
|
| 48 |
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
|
| 49 |
<p class="theme-text-secondary">Loading {{ media_type }}s...</p>
|
| 50 |
</div>
|
| 51 |
-
|
| 52 |
<!-- Error Display -->
|
| 53 |
<div id="error-display" class="hidden mb-8">
|
| 54 |
<div class="theme-card p-6 rounded-lg shadow border-l-4 border-red-500">
|
| 55 |
<div class="flex items-center mb-4">
|
| 56 |
<svg class="h-6 w-6 text-red-500 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 57 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
|
|
| 58 |
</svg>
|
| 59 |
<h3 class="text-lg font-semibold text-red-700">Error Loading Media</h3>
|
| 60 |
</div>
|
| 61 |
<p id="error-message" class="text-red-600 mb-4"></p>
|
| 62 |
-
<button onclick="retryLoad()"
|
| 63 |
-
|
| 64 |
Try Again
|
| 65 |
</button>
|
| 66 |
</div>
|
| 67 |
</div>
|
| 68 |
-
|
| 69 |
<!-- Media Grid -->
|
| 70 |
-
<div id="media-grid"
|
|
|
|
| 71 |
<!-- Media items will be loaded here via JavaScript -->
|
| 72 |
</div>
|
| 73 |
-
|
| 74 |
<!-- No Results Message -->
|
| 75 |
<div id="no-results" class="text-center py-12 hidden">
|
| 76 |
<div class="theme-card p-8 rounded-lg shadow">
|
| 77 |
<svg class="mx-auto h-16 w-16 text-gray-300 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 78 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
|
|
| 79 |
</svg>
|
| 80 |
<h3 class="text-lg font-semibold theme-text-secondary mb-2">No {{ media_type }}s found</h3>
|
| 81 |
<p class="theme-text-secondary text-sm mb-4">
|
|
@@ -85,21 +88,22 @@
|
|
| 85 |
No TV shows match your search criteria
|
| 86 |
{% endif %}
|
| 87 |
</p>
|
| 88 |
-
<button onclick="clearSearch()"
|
| 89 |
-
|
| 90 |
Show All {{ media_type|title }}s
|
| 91 |
</button>
|
| 92 |
</div>
|
| 93 |
</div>
|
| 94 |
-
|
| 95 |
<!-- Pagination -->
|
| 96 |
<div id="pagination" class="flex justify-center items-center space-x-2 mt-8 hidden">
|
| 97 |
<!-- Pagination will be loaded here via JavaScript -->
|
| 98 |
</div>
|
| 99 |
-
|
| 100 |
<!-- Media Info -->
|
| 101 |
<div id="media-info" class="theme-card p-4 rounded-lg shadow mt-6 hidden">
|
| 102 |
-
<div
|
|
|
|
| 103 |
<span id="results-info"></span>
|
| 104 |
<span id="db-info"></span>
|
| 105 |
</div>
|
|
@@ -107,112 +111,112 @@
|
|
| 107 |
</div>
|
| 108 |
|
| 109 |
<script>
|
| 110 |
-
let currentPage = 1;
|
| 111 |
-
let currentSearch = '';
|
| 112 |
-
let isLoading = false;
|
| 113 |
-
const mediaType = '{{ media_type }}';
|
| 114 |
-
|
| 115 |
-
// Show loading state
|
| 116 |
-
function showLoading() {
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
// Hide loading state
|
| 127 |
-
function hideLoading() {
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
// Show error state
|
| 134 |
-
function showError(message) {
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
}
|
| 141 |
-
|
| 142 |
-
// Load media with proper error handling
|
| 143 |
-
async function loadMedia(page = 1, search = '') {
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
}
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
return;
|
| 169 |
}
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
document.getElementById('media-info').classList.add('hidden');
|
| 187 |
-
return;
|
| 188 |
}
|
| 189 |
-
|
| 190 |
-
document.getElementById('no-results').classList.add('hidden');
|
| 191 |
-
|
| 192 |
-
// Render media items
|
| 193 |
-
grid.innerHTML = mediaItems.map(item => createMediaCard(item)).join('');
|
| 194 |
-
|
| 195 |
-
// Update pagination
|
| 196 |
-
updatePagination(data.current_page || page, data.total_pages || 1);
|
| 197 |
-
|
| 198 |
-
// Update info
|
| 199 |
-
updateMediaInfo(data);
|
| 200 |
-
|
| 201 |
-
} catch (error) {
|
| 202 |
-
console.error('Error loading media:', error);
|
| 203 |
-
hideLoading();
|
| 204 |
-
showError(`Failed to load ${mediaType}s: ${error.message}`);
|
| 205 |
}
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
return `
|
| 216 |
<div class="theme-card rounded-lg shadow hover:shadow-lg transition-all duration-300 group overflow-hidden">
|
| 217 |
<div class="relative">
|
| 218 |
<div class="aspect-w-2 aspect-h-3">
|
|
@@ -245,232 +249,232 @@ function createMediaCard(item) {
|
|
| 245 |
</div>
|
| 246 |
</div>
|
| 247 |
`;
|
| 248 |
-
}
|
| 249 |
-
|
| 250 |
-
// Update pagination
|
| 251 |
-
function updatePagination(currentPage, totalPages) {
|
| 252 |
-
const pagination = document.getElementById('pagination');
|
| 253 |
-
|
| 254 |
-
if (totalPages <= 1) {
|
| 255 |
-
pagination.classList.add('hidden');
|
| 256 |
-
return;
|
| 257 |
}
|
| 258 |
-
|
| 259 |
-
pagination
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
<button onclick="loadMedia(${currentPage - 1}, '${currentSearch}')"
|
| 266 |
class="px-3 py-2 border rounded-md hover:bg-gray-50 transition-colors text-sm">
|
| 267 |
Previous
|
| 268 |
</button>
|
| 269 |
`;
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
<button onclick="loadMedia(1, '${currentSearch}')"
|
| 280 |
class="px-3 py-2 border rounded-md hover:bg-gray-50 transition-colors text-sm">
|
| 281 |
1
|
| 282 |
</button>
|
| 283 |
`;
|
| 284 |
-
|
| 285 |
-
|
|
|
|
| 286 |
}
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
paginationHTML += `
|
| 293 |
<button onclick="loadMedia(${i}, '${currentSearch}')"
|
| 294 |
-
class="px-3 py-2 border rounded-md transition-colors text-sm ${
|
| 295 |
-
|
| 296 |
-
}">
|
| 297 |
${i}
|
| 298 |
</button>
|
| 299 |
`;
|
| 300 |
-
}
|
| 301 |
-
|
| 302 |
-
// Last page
|
| 303 |
-
if (endPage < totalPages) {
|
| 304 |
-
if (endPage < totalPages - 1) {
|
| 305 |
-
paginationHTML += `<span class="px-3 py-2 text-sm">...</span>`;
|
| 306 |
}
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
<button onclick="loadMedia(${totalPages}, '${currentSearch}')"
|
| 309 |
class="px-3 py-2 border rounded-md hover:bg-gray-50 transition-colors text-sm">
|
| 310 |
${totalPages}
|
| 311 |
</button>
|
| 312 |
`;
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
<button onclick="loadMedia(${currentPage + 1}, '${currentSearch}')"
|
| 319 |
class="px-3 py-2 border rounded-md hover:bg-gray-50 transition-colors text-sm">
|
| 320 |
Next
|
| 321 |
</button>
|
| 322 |
`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
}
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
const startItem = ((data.current_page - 1) * 24) + 1;
|
| 335 |
-
const endItem = Math.min(data.current_page * 24, data.total_count);
|
| 336 |
-
|
| 337 |
-
resultsInfo.textContent = `Showing ${startItem}-${endItem} of ${data.total_count} ${mediaType}s`;
|
| 338 |
-
|
| 339 |
-
if (data.databases_checked && data.databases_checked.length > 0) {
|
| 340 |
-
dbInfo.textContent = `Database${data.databases_checked.length > 1 ? 's' : ''}: ${data.databases_checked.join(', ')}`;
|
| 341 |
}
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
'Accept': 'application/json',
|
| 374 |
-
'Content-Type': 'application/json'
|
| 375 |
}
|
| 376 |
-
})
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
// Show success message briefly
|
| 380 |
-
const successMsg = document.createElement('div');
|
| 381 |
-
successMsg.className = 'fixed top-4 right-4 bg-green-500 text-white px-4 py-2 rounded-md shadow-lg z-50';
|
| 382 |
-
successMsg.textContent = `"${title}" deleted successfully`;
|
| 383 |
-
document.body.appendChild(successMsg);
|
| 384 |
-
|
| 385 |
-
setTimeout(() => {
|
| 386 |
-
document.body.removeChild(successMsg);
|
| 387 |
-
}, 3000);
|
| 388 |
-
|
| 389 |
-
// Reload current page
|
| 390 |
-
loadMedia(currentPage, currentSearch);
|
| 391 |
-
} else {
|
| 392 |
-
const error = await response.json();
|
| 393 |
-
alert(`Error deleting "${title}": ${error.detail || 'Unknown error'}`);
|
| 394 |
}
|
| 395 |
-
} catch (error) {
|
| 396 |
-
console.error('Error deleting media:', error);
|
| 397 |
-
alert(`Error deleting "${title}": ${error.message}`);
|
| 398 |
}
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
}
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
}
|
| 424 |
-
}
|
| 425 |
});
|
| 426 |
-
});
|
| 427 |
</script>
|
| 428 |
|
| 429 |
<style>
|
| 430 |
-
.line-clamp-2 {
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
}
|
| 436 |
-
|
| 437 |
-
.aspect-w-2 {
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
@media (max-width: 640px) {
|
| 453 |
-
.grid {
|
| 454 |
-
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 455 |
-
gap: 0.75rem;
|
| 456 |
}
|
| 457 |
-
}
|
| 458 |
|
| 459 |
-
@media (
|
| 460 |
-
|
| 461 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
}
|
| 463 |
-
}
|
| 464 |
|
| 465 |
-
/* Loading animation */
|
| 466 |
-
@keyframes spin {
|
| 467 |
-
|
| 468 |
-
|
|
|
|
| 469 |
}
|
| 470 |
-
}
|
| 471 |
|
| 472 |
-
.animate-spin {
|
| 473 |
-
|
| 474 |
-
}
|
| 475 |
</style>
|
| 476 |
-
{% endblock %}
|
|
|
|
| 10 |
<h1 class="text-3xl font-bold">Media Management</h1>
|
| 11 |
<p class="theme-text-secondary mt-2">Manage your movie and TV show collections</p>
|
| 12 |
</div>
|
| 13 |
+
|
| 14 |
<div class="flex space-x-2">
|
| 15 |
+
<a href="/media/manage?media_type=movie"
|
| 16 |
+
class="px-4 py-2 rounded-md transition-colors text-sm font-medium {% if media_type == 'movie' %}bg-blue-600 text-white{% else %}theme-card border hover:bg-gray-50{% endif %}">
|
| 17 |
Movies
|
| 18 |
</a>
|
| 19 |
+
<a href="/media/manage?media_type=tv"
|
| 20 |
+
class="px-4 py-2 rounded-md transition-colors text-sm font-medium {% if media_type == 'tv' %}bg-blue-600 text-white{% else %}theme-card border hover:bg-gray-50{% endif %}">
|
| 21 |
TV Shows
|
| 22 |
</a>
|
| 23 |
</div>
|
| 24 |
</div>
|
| 25 |
+
|
| 26 |
<!-- Search and Filters -->
|
| 27 |
<div class="theme-card p-6 rounded-lg shadow mb-8">
|
| 28 |
<div class="flex flex-col md:flex-row gap-4">
|
| 29 |
<div class="flex-1">
|
| 30 |
+
<input type="text" id="search-input" placeholder="Search {{ media_type }}s by title..."
|
| 31 |
+
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm">
|
| 32 |
</div>
|
| 33 |
<div class="flex gap-2">
|
| 34 |
+
<button onclick="searchMedia()"
|
| 35 |
+
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md transition-colors text-sm font-medium shadow-sm">
|
| 36 |
Search
|
| 37 |
</button>
|
| 38 |
+
<button onclick="clearSearch()"
|
| 39 |
+
class="border border-gray-300 px-6 py-2 rounded-md hover:bg-gray-50 transition-colors text-sm font-medium">
|
| 40 |
Clear
|
| 41 |
</button>
|
| 42 |
</div>
|
| 43 |
</div>
|
| 44 |
</div>
|
| 45 |
+
|
| 46 |
<!-- Loading Indicator -->
|
| 47 |
<div id="loading" class="text-center py-12 hidden">
|
| 48 |
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
|
| 49 |
<p class="theme-text-secondary">Loading {{ media_type }}s...</p>
|
| 50 |
</div>
|
| 51 |
+
|
| 52 |
<!-- Error Display -->
|
| 53 |
<div id="error-display" class="hidden mb-8">
|
| 54 |
<div class="theme-card p-6 rounded-lg shadow border-l-4 border-red-500">
|
| 55 |
<div class="flex items-center mb-4">
|
| 56 |
<svg class="h-6 w-6 text-red-500 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 57 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
| 58 |
+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.996-.833-2.764 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
| 59 |
</svg>
|
| 60 |
<h3 class="text-lg font-semibold text-red-700">Error Loading Media</h3>
|
| 61 |
</div>
|
| 62 |
<p id="error-message" class="text-red-600 mb-4"></p>
|
| 63 |
+
<button onclick="retryLoad()"
|
| 64 |
+
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md transition-colors text-sm shadow-sm">
|
| 65 |
Try Again
|
| 66 |
</button>
|
| 67 |
</div>
|
| 68 |
</div>
|
| 69 |
+
|
| 70 |
<!-- Media Grid -->
|
| 71 |
+
<div id="media-grid"
|
| 72 |
+
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 gap-4 mb-8">
|
| 73 |
<!-- Media items will be loaded here via JavaScript -->
|
| 74 |
</div>
|
| 75 |
+
|
| 76 |
<!-- No Results Message -->
|
| 77 |
<div id="no-results" class="text-center py-12 hidden">
|
| 78 |
<div class="theme-card p-8 rounded-lg shadow">
|
| 79 |
<svg class="mx-auto h-16 w-16 text-gray-300 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 80 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
| 81 |
+
d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 110 2h-1v12a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 110-2h4zM9 6v10h6V6H9z" />
|
| 82 |
</svg>
|
| 83 |
<h3 class="text-lg font-semibold theme-text-secondary mb-2">No {{ media_type }}s found</h3>
|
| 84 |
<p class="theme-text-secondary text-sm mb-4">
|
|
|
|
| 88 |
No TV shows match your search criteria
|
| 89 |
{% endif %}
|
| 90 |
</p>
|
| 91 |
+
<button onclick="clearSearch()"
|
| 92 |
+
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md transition-colors text-sm shadow-sm">
|
| 93 |
Show All {{ media_type|title }}s
|
| 94 |
</button>
|
| 95 |
</div>
|
| 96 |
</div>
|
| 97 |
+
|
| 98 |
<!-- Pagination -->
|
| 99 |
<div id="pagination" class="flex justify-center items-center space-x-2 mt-8 hidden">
|
| 100 |
<!-- Pagination will be loaded here via JavaScript -->
|
| 101 |
</div>
|
| 102 |
+
|
| 103 |
<!-- Media Info -->
|
| 104 |
<div id="media-info" class="theme-card p-4 rounded-lg shadow mt-6 hidden">
|
| 105 |
+
<div
|
| 106 |
+
class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 text-sm theme-text-secondary">
|
| 107 |
<span id="results-info"></span>
|
| 108 |
<span id="db-info"></span>
|
| 109 |
</div>
|
|
|
|
| 111 |
</div>
|
| 112 |
|
| 113 |
<script>
|
| 114 |
+
let currentPage = 1;
|
| 115 |
+
let currentSearch = '';
|
| 116 |
+
let isLoading = false;
|
| 117 |
+
const mediaType = '{{ media_type }}';
|
| 118 |
+
|
| 119 |
+
// Show loading state
|
| 120 |
+
function showLoading() {
|
| 121 |
+
isLoading = true;
|
| 122 |
+
document.getElementById('loading').classList.remove('hidden');
|
| 123 |
+
document.getElementById('media-grid').classList.add('hidden');
|
| 124 |
+
document.getElementById('no-results').classList.add('hidden');
|
| 125 |
+
document.getElementById('pagination').classList.add('hidden');
|
| 126 |
+
document.getElementById('media-info').classList.add('hidden');
|
| 127 |
+
document.getElementById('error-display').classList.add('hidden');
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// Hide loading state
|
| 131 |
+
function hideLoading() {
|
| 132 |
+
isLoading = false;
|
| 133 |
+
document.getElementById('loading').classList.add('hidden');
|
| 134 |
+
document.getElementById('media-grid').classList.remove('hidden');
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Show error state
|
| 138 |
+
function showError(message) {
|
| 139 |
+
document.getElementById('error-message').textContent = message;
|
| 140 |
+
document.getElementById('error-display').classList.remove('hidden');
|
| 141 |
+
document.getElementById('loading').classList.add('hidden');
|
| 142 |
+
document.getElementById('media-grid').classList.add('hidden');
|
| 143 |
+
document.getElementById('no-results').classList.add('hidden');
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// Load media with proper error handling
|
| 147 |
+
async function loadMedia(page = 1, search = '') {
|
| 148 |
+
if (isLoading) return; // Prevent multiple simultaneous requests
|
| 149 |
+
|
| 150 |
+
showLoading();
|
| 151 |
+
currentPage = page;
|
| 152 |
+
currentSearch = search;
|
| 153 |
+
|
| 154 |
+
try {
|
| 155 |
+
const url = `/api/media/list?media_type=${mediaType}&page=${page}&page_size=24&search=${encodeURIComponent(search)}`;
|
| 156 |
+
console.log('Fetching:', url);
|
| 157 |
+
|
| 158 |
+
const response = await fetch(url, {
|
| 159 |
+
method: 'GET',
|
| 160 |
+
credentials: 'same-origin',
|
| 161 |
+
headers: {
|
| 162 |
+
'Accept': 'application/json',
|
| 163 |
+
'Content-Type': 'application/json'
|
| 164 |
+
}
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
console.log('Response status:', response.status);
|
| 168 |
+
|
| 169 |
+
if (!response.ok) {
|
| 170 |
+
if (response.status === 401) {
|
| 171 |
+
window.location.href = '/login';
|
| 172 |
+
return;
|
| 173 |
+
}
|
| 174 |
+
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
|
| 175 |
}
|
| 176 |
+
|
| 177 |
+
const data = await response.json();
|
| 178 |
+
console.log('Response data:', data);
|
| 179 |
+
|
| 180 |
+
hideLoading();
|
| 181 |
+
|
| 182 |
+
const grid = document.getElementById('media-grid');
|
| 183 |
+
const mediaKey = mediaType === 'movie' ? 'movies' : 'tv_shows';
|
| 184 |
+
const mediaItems = data[mediaKey] || [];
|
| 185 |
+
|
| 186 |
+
if (mediaItems.length === 0) {
|
| 187 |
+
grid.innerHTML = '';
|
| 188 |
+
document.getElementById('no-results').classList.remove('hidden');
|
| 189 |
+
document.getElementById('pagination').classList.add('hidden');
|
| 190 |
+
document.getElementById('media-info').classList.add('hidden');
|
| 191 |
return;
|
| 192 |
}
|
| 193 |
+
|
| 194 |
+
document.getElementById('no-results').classList.add('hidden');
|
| 195 |
+
|
| 196 |
+
// Render media items
|
| 197 |
+
grid.innerHTML = mediaItems.map(item => createMediaCard(item)).join('');
|
| 198 |
+
|
| 199 |
+
// Update pagination
|
| 200 |
+
updatePagination(data.current_page || page, data.total_pages || 1);
|
| 201 |
+
|
| 202 |
+
// Update info
|
| 203 |
+
updateMediaInfo(data);
|
| 204 |
+
|
| 205 |
+
} catch (error) {
|
| 206 |
+
console.error('Error loading media:', error);
|
| 207 |
+
hideLoading();
|
| 208 |
+
showError(`Failed to load ${mediaType}s: ${error.message}`);
|
|
|
|
|
|
|
| 209 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
}
|
| 211 |
+
|
| 212 |
+
// Create media card HTML
|
| 213 |
+
function createMediaCard(item) {
|
| 214 |
+
const title = item.title || item.name || 'Unknown Title';
|
| 215 |
+
const year = item.release_year || 'Unknown Year';
|
| 216 |
+
const rating = item.rating ? `β ${parseFloat(item.rating).toFixed(1)}` : '';
|
| 217 |
+
const poster = item.poster || 'https://via.placeholder.com/300x450/e5e7eb/9ca3af?text=No+Image';
|
| 218 |
+
|
| 219 |
+
return `
|
|
|
|
| 220 |
<div class="theme-card rounded-lg shadow hover:shadow-lg transition-all duration-300 group overflow-hidden">
|
| 221 |
<div class="relative">
|
| 222 |
<div class="aspect-w-2 aspect-h-3">
|
|
|
|
| 249 |
</div>
|
| 250 |
</div>
|
| 251 |
`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
}
|
| 253 |
+
|
| 254 |
+
// Update pagination
|
| 255 |
+
function updatePagination(currentPage, totalPages) {
|
| 256 |
+
const pagination = document.getElementById('pagination');
|
| 257 |
+
|
| 258 |
+
if (totalPages <= 1) {
|
| 259 |
+
pagination.classList.add('hidden');
|
| 260 |
+
return;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
pagination.classList.remove('hidden');
|
| 264 |
+
let paginationHTML = '';
|
| 265 |
+
|
| 266 |
+
// Previous button
|
| 267 |
+
if (currentPage > 1) {
|
| 268 |
+
paginationHTML += `
|
| 269 |
<button onclick="loadMedia(${currentPage - 1}, '${currentSearch}')"
|
| 270 |
class="px-3 py-2 border rounded-md hover:bg-gray-50 transition-colors text-sm">
|
| 271 |
Previous
|
| 272 |
</button>
|
| 273 |
`;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// Page numbers
|
| 277 |
+
const startPage = Math.max(1, currentPage - 2);
|
| 278 |
+
const endPage = Math.min(totalPages, currentPage + 2);
|
| 279 |
+
|
| 280 |
+
// First page
|
| 281 |
+
if (startPage > 1) {
|
| 282 |
+
paginationHTML += `
|
| 283 |
<button onclick="loadMedia(1, '${currentSearch}')"
|
| 284 |
class="px-3 py-2 border rounded-md hover:bg-gray-50 transition-colors text-sm">
|
| 285 |
1
|
| 286 |
</button>
|
| 287 |
`;
|
| 288 |
+
if (startPage > 2) {
|
| 289 |
+
paginationHTML += `<span class="px-3 py-2 text-sm">...</span>`;
|
| 290 |
+
}
|
| 291 |
}
|
| 292 |
+
|
| 293 |
+
// Page range
|
| 294 |
+
for (let i = startPage; i <= endPage; i++) {
|
| 295 |
+
const isActive = i === currentPage;
|
| 296 |
+
paginationHTML += `
|
|
|
|
| 297 |
<button onclick="loadMedia(${i}, '${currentSearch}')"
|
| 298 |
+
class="px-3 py-2 border rounded-md transition-colors text-sm ${isActive ? 'bg-blue-600 text-white border-blue-600' : 'hover:bg-gray-50'
|
| 299 |
+
}">
|
|
|
|
| 300 |
${i}
|
| 301 |
</button>
|
| 302 |
`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
}
|
| 304 |
+
|
| 305 |
+
// Last page
|
| 306 |
+
if (endPage < totalPages) {
|
| 307 |
+
if (endPage < totalPages - 1) {
|
| 308 |
+
paginationHTML += `<span class="px-3 py-2 text-sm">...</span>`;
|
| 309 |
+
}
|
| 310 |
+
paginationHTML += `
|
| 311 |
<button onclick="loadMedia(${totalPages}, '${currentSearch}')"
|
| 312 |
class="px-3 py-2 border rounded-md hover:bg-gray-50 transition-colors text-sm">
|
| 313 |
${totalPages}
|
| 314 |
</button>
|
| 315 |
`;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
// Next button
|
| 319 |
+
if (currentPage < totalPages) {
|
| 320 |
+
paginationHTML += `
|
| 321 |
<button onclick="loadMedia(${currentPage + 1}, '${currentSearch}')"
|
| 322 |
class="px-3 py-2 border rounded-md hover:bg-gray-50 transition-colors text-sm">
|
| 323 |
Next
|
| 324 |
</button>
|
| 325 |
`;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
pagination.innerHTML = paginationHTML;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
// Update media info display
|
| 332 |
+
function updateMediaInfo(data) {
|
| 333 |
+
const mediaInfo = document.getElementById('media-info');
|
| 334 |
+
const resultsInfo = document.getElementById('results-info');
|
| 335 |
+
const dbInfo = document.getElementById('db-info');
|
| 336 |
+
|
| 337 |
+
const startItem = ((data.current_page - 1) * 24) + 1;
|
| 338 |
+
const endItem = Math.min(data.current_page * 24, data.total_count);
|
| 339 |
+
|
| 340 |
+
resultsInfo.textContent = `Showing ${startItem}-${endItem} of ${data.total_count} ${mediaType}s`;
|
| 341 |
+
|
| 342 |
+
if (data.databases_checked && data.databases_checked.length > 0) {
|
| 343 |
+
dbInfo.textContent = `Database${data.databases_checked.length > 1 ? 's' : ''}: ${data.databases_checked.join(', ')}`;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
mediaInfo.classList.remove('hidden');
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
// Search functionality
|
| 350 |
+
function searchMedia() {
|
| 351 |
+
const searchInput = document.getElementById('search-input');
|
| 352 |
+
const search = searchInput.value.trim();
|
| 353 |
+
loadMedia(1, search);
|
| 354 |
}
|
| 355 |
+
|
| 356 |
+
// Clear search
|
| 357 |
+
function clearSearch() {
|
| 358 |
+
document.getElementById('search-input').value = '';
|
| 359 |
+
loadMedia(1, '');
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
// Retry load function
|
| 363 |
+
function retryLoad() {
|
| 364 |
+
loadMedia(currentPage, currentSearch);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
}
|
| 366 |
+
|
| 367 |
+
// Delete media with confirmation
|
| 368 |
+
async function deleteMedia(tmdbId, dbIndex, title) {
|
| 369 |
+
if (!confirm(`Are you sure you want to delete "${title}"?\n\nThis action cannot be undone.`)) return;
|
| 370 |
+
|
| 371 |
+
try {
|
| 372 |
+
const response = await fetch(`/api/media/delete?tmdb_id=${tmdbId}&db_index=${dbIndex}&media_type=${mediaType}`, {
|
| 373 |
+
method: 'DELETE',
|
| 374 |
+
credentials: 'same-origin',
|
| 375 |
+
headers: {
|
| 376 |
+
'Accept': 'application/json',
|
| 377 |
+
'Content-Type': 'application/json'
|
| 378 |
+
}
|
| 379 |
+
});
|
| 380 |
+
|
| 381 |
+
if (response.ok) {
|
| 382 |
+
// Show success message briefly
|
| 383 |
+
const successMsg = document.createElement('div');
|
| 384 |
+
successMsg.className = 'fixed top-4 right-4 bg-green-500 text-white px-4 py-2 rounded-md shadow-lg z-50';
|
| 385 |
+
successMsg.textContent = `"${title}" deleted successfully`;
|
| 386 |
+
document.body.appendChild(successMsg);
|
| 387 |
+
|
| 388 |
+
setTimeout(() => {
|
| 389 |
+
document.body.removeChild(successMsg);
|
| 390 |
+
}, 3000);
|
| 391 |
+
|
| 392 |
+
// Reload current page
|
| 393 |
+
loadMedia(currentPage, currentSearch);
|
| 394 |
+
} else {
|
| 395 |
+
const error = await response.json();
|
| 396 |
+
alert(`Error deleting "${title}": ${error.detail || 'Unknown error'}`);
|
|
|
|
|
|
|
| 397 |
}
|
| 398 |
+
} catch (error) {
|
| 399 |
+
console.error('Error deleting media:', error);
|
| 400 |
+
alert(`Error deleting "${title}": ${error.message}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
}
|
|
|
|
|
|
|
|
|
|
| 402 |
}
|
| 403 |
+
|
| 404 |
+
// Initialize page
|
| 405 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 406 |
+
// Load media on page load
|
| 407 |
+
loadMedia();
|
| 408 |
+
|
| 409 |
+
// Add enter key listener to search input
|
| 410 |
+
document.getElementById('search-input').addEventListener('keypress', (e) => {
|
| 411 |
+
if (e.key === 'Enter') {
|
| 412 |
+
e.preventDefault();
|
| 413 |
+
searchMedia();
|
| 414 |
+
}
|
| 415 |
+
});
|
| 416 |
+
|
| 417 |
+
// Add input event for real-time search (with debouncing)
|
| 418 |
+
let searchTimeout;
|
| 419 |
+
document.getElementById('search-input').addEventListener('input', (e) => {
|
| 420 |
+
clearTimeout(searchTimeout);
|
| 421 |
+
const searchValue = e.target.value.trim();
|
| 422 |
+
|
| 423 |
+
if (searchValue.length === 0) {
|
| 424 |
+
searchTimeout = setTimeout(() => {
|
| 425 |
+
loadMedia(1, '');
|
| 426 |
+
}, 300);
|
| 427 |
+
}
|
| 428 |
+
});
|
| 429 |
});
|
|
|
|
| 430 |
</script>
|
| 431 |
|
| 432 |
<style>
|
| 433 |
+
.line-clamp-2 {
|
| 434 |
+
display: -webkit-box;
|
| 435 |
+
-webkit-line-clamp: 2;
|
| 436 |
+
-webkit-box-orient: vertical;
|
| 437 |
+
overflow: hidden;
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
.aspect-w-2 {
|
| 441 |
+
position: relative;
|
| 442 |
+
padding-bottom: 150%;
|
| 443 |
+
/* 2:3 aspect ratio */
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.aspect-w-2>img {
|
| 447 |
+
position: absolute;
|
| 448 |
+
height: 100%;
|
| 449 |
+
width: 100%;
|
| 450 |
+
top: 0;
|
| 451 |
+
right: 0;
|
| 452 |
+
bottom: 0;
|
| 453 |
+
left: 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
}
|
|
|
|
| 455 |
|
| 456 |
+
@media (max-width: 640px) {
|
| 457 |
+
.grid {
|
| 458 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 459 |
+
gap: 0.75rem;
|
| 460 |
+
}
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
@media (min-width: 1536px) {
|
| 464 |
+
.grid {
|
| 465 |
+
grid-template-columns: repeat(8, minmax(0, 1fr));
|
| 466 |
+
}
|
| 467 |
}
|
|
|
|
| 468 |
|
| 469 |
+
/* Loading animation */
|
| 470 |
+
@keyframes spin {
|
| 471 |
+
to {
|
| 472 |
+
transform: rotate(360deg);
|
| 473 |
+
}
|
| 474 |
}
|
|
|
|
| 475 |
|
| 476 |
+
.animate-spin {
|
| 477 |
+
animation: spin 1s linear infinite;
|
| 478 |
+
}
|
| 479 |
</style>
|
| 480 |
+
{% endblock %}
|
Backend/fastapi/templates/public_status.html
CHANGED
|
@@ -9,7 +9,7 @@
|
|
| 9 |
<h1 class="text-3xl font-bold">System Status</h1>
|
| 10 |
<p class="theme-text-secondary mt-2">Current status of Telegram Stremio services</p>
|
| 11 |
</div>
|
| 12 |
-
|
| 13 |
<!-- Status Cards -->
|
| 14 |
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
| 15 |
<div class="theme-card p-6 rounded-lg shadow text-center">
|
|
@@ -19,7 +19,7 @@
|
|
| 19 |
<h3 class="text-lg font-semibold mb-2">Backend Status</h3>
|
| 20 |
<p class="text-green-600 font-medium">{{ stats.status|title }}</p>
|
| 21 |
</div>
|
| 22 |
-
|
| 23 |
<div class="theme-card p-6 rounded-lg shadow text-center">
|
| 24 |
<div class="w-16 h-16 mx-auto mb-4 bg-blue-500 rounded-full flex items-center justify-center">
|
| 25 |
<span class="text-white text-2xl">β</span>
|
|
@@ -27,7 +27,7 @@
|
|
| 27 |
<h3 class="text-lg font-semibold mb-2">Uptime</h3>
|
| 28 |
<p class="text-green-600 font-medium">{{ stats.uptime }}</p>
|
| 29 |
</div>
|
| 30 |
-
|
| 31 |
<div class="theme-card p-6 rounded-lg shadow text-center">
|
| 32 |
<div class="w-16 h-16 mx-auto mb-4 bg-purple-500 rounded-full flex items-center justify-center">
|
| 33 |
<span class="text-white text-2xl">v</span>
|
|
@@ -36,7 +36,7 @@
|
|
| 36 |
<p class="theme-text-secondary font-medium">{{ stats.version }}</p>
|
| 37 |
</div>
|
| 38 |
</div>
|
| 39 |
-
|
| 40 |
<!-- Services Status -->
|
| 41 |
<div class="theme-card p-6 rounded-lg shadow mb-8">
|
| 42 |
<h2 class="text-xl font-semibold mb-4">Service Status</h2>
|
|
@@ -55,16 +55,17 @@
|
|
| 55 |
</div>
|
| 56 |
</div>
|
| 57 |
</div>
|
| 58 |
-
|
| 59 |
{% if not is_authenticated %}
|
| 60 |
<!-- Admin Access -->
|
| 61 |
<div class="theme-card p-6 rounded-lg shadow text-center">
|
| 62 |
<h2 class="text-xl font-semibold mb-4">Administrator Access</h2>
|
| 63 |
<p class="theme-text-secondary mb-4">Login to access the full admin dashboard</p>
|
| 64 |
-
<a href="/login"
|
|
|
|
| 65 |
Admin Login
|
| 66 |
</a>
|
| 67 |
</div>
|
| 68 |
{% endif %}
|
| 69 |
</div>
|
| 70 |
-
{% endblock %}
|
|
|
|
| 9 |
<h1 class="text-3xl font-bold">System Status</h1>
|
| 10 |
<p class="theme-text-secondary mt-2">Current status of Telegram Stremio services</p>
|
| 11 |
</div>
|
| 12 |
+
|
| 13 |
<!-- Status Cards -->
|
| 14 |
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
| 15 |
<div class="theme-card p-6 rounded-lg shadow text-center">
|
|
|
|
| 19 |
<h3 class="text-lg font-semibold mb-2">Backend Status</h3>
|
| 20 |
<p class="text-green-600 font-medium">{{ stats.status|title }}</p>
|
| 21 |
</div>
|
| 22 |
+
|
| 23 |
<div class="theme-card p-6 rounded-lg shadow text-center">
|
| 24 |
<div class="w-16 h-16 mx-auto mb-4 bg-blue-500 rounded-full flex items-center justify-center">
|
| 25 |
<span class="text-white text-2xl">β</span>
|
|
|
|
| 27 |
<h3 class="text-lg font-semibold mb-2">Uptime</h3>
|
| 28 |
<p class="text-green-600 font-medium">{{ stats.uptime }}</p>
|
| 29 |
</div>
|
| 30 |
+
|
| 31 |
<div class="theme-card p-6 rounded-lg shadow text-center">
|
| 32 |
<div class="w-16 h-16 mx-auto mb-4 bg-purple-500 rounded-full flex items-center justify-center">
|
| 33 |
<span class="text-white text-2xl">v</span>
|
|
|
|
| 36 |
<p class="theme-text-secondary font-medium">{{ stats.version }}</p>
|
| 37 |
</div>
|
| 38 |
</div>
|
| 39 |
+
|
| 40 |
<!-- Services Status -->
|
| 41 |
<div class="theme-card p-6 rounded-lg shadow mb-8">
|
| 42 |
<h2 class="text-xl font-semibold mb-4">Service Status</h2>
|
|
|
|
| 55 |
</div>
|
| 56 |
</div>
|
| 57 |
</div>
|
| 58 |
+
|
| 59 |
{% if not is_authenticated %}
|
| 60 |
<!-- Admin Access -->
|
| 61 |
<div class="theme-card p-6 rounded-lg shadow text-center">
|
| 62 |
<h2 class="text-xl font-semibold mb-4">Administrator Access</h2>
|
| 63 |
<p class="theme-text-secondary mb-4">Login to access the full admin dashboard</p>
|
| 64 |
+
<a href="/login"
|
| 65 |
+
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-md transition-colors inline-block shadow-sm">
|
| 66 |
Admin Login
|
| 67 |
</a>
|
| 68 |
</div>
|
| 69 |
{% endif %}
|
| 70 |
</div>
|
| 71 |
+
{% endblock %}
|
Backend/helper/custom_dl.py
CHANGED
|
@@ -2,7 +2,7 @@ import asyncio
|
|
| 2 |
import time
|
| 3 |
import secrets
|
| 4 |
from collections import deque
|
| 5 |
-
from typing import Dict, Union, Optional, Tuple
|
| 6 |
import traceback
|
| 7 |
from fastapi import Request
|
| 8 |
from pyrogram import Client, raw, utils
|
|
@@ -12,19 +12,45 @@ from pyrogram.session import Session, Auth
|
|
| 12 |
from Backend.logger import LOGGER
|
| 13 |
from Backend.helper.exceptions import FIleNotFound
|
| 14 |
from Backend.helper.pyro import get_file_ids
|
| 15 |
-
from Backend
|
|
|
|
| 16 |
|
| 17 |
ACTIVE_STREAMS: Dict[str, Dict] = {}
|
| 18 |
RECENT_STREAMS = deque(maxlen=3)
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
class ByteStreamer:
|
| 21 |
CHUNK_SIZE = 1024 * 1024 # 1 MB
|
| 22 |
CLEAN_INTERVAL = 30 * 60 # 30 minutes
|
|
|
|
| 23 |
|
| 24 |
-
def __init__(self, client: Client):
|
| 25 |
self.client = client
|
|
|
|
| 26 |
self._file_id_cache: Dict[int, FileId] = {}
|
| 27 |
self._session_lock = asyncio.Lock()
|
|
|
|
|
|
|
|
|
|
| 28 |
asyncio.create_task(self._clean_cache())
|
| 29 |
asyncio.create_task(self._prewarm_sessions())
|
| 30 |
|
|
@@ -138,23 +164,81 @@ class ByteStreamer:
|
|
| 138 |
location = await self._get_location(file_id)
|
| 139 |
|
| 140 |
async def fetch_chunk_with_retries(seq_idx: int, off: int) -> Tuple[int, Optional[bytes]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
tries = 0
|
| 142 |
-
while tries <
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
try:
|
| 144 |
-
r = await
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
)
|
| 147 |
chunk_bytes = getattr(r, "bytes", None) if r else None
|
|
|
|
|
|
|
|
|
|
| 148 |
return seq_idx, chunk_bytes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
except Exception as e:
|
| 150 |
tries += 1
|
| 151 |
LOGGER.debug(
|
| 152 |
-
"Fetch chunk error seq=%s off=%s try=%s err=%s",
|
| 153 |
-
seq_idx, off, tries, getattr(e, "args", e),
|
| 154 |
)
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
return seq_idx, None
|
| 159 |
|
| 160 |
async def producer():
|
|
@@ -329,7 +413,7 @@ class ByteStreamer:
|
|
| 329 |
try:
|
| 330 |
producer_task.cancel()
|
| 331 |
await asyncio.wait_for(producer_task, timeout=2.0)
|
| 332 |
-
except Exception:
|
| 333 |
pass
|
| 334 |
|
| 335 |
try:
|
|
@@ -344,14 +428,31 @@ class ByteStreamer:
|
|
| 344 |
"end_ts": end_ts,
|
| 345 |
"duration": duration,
|
| 346 |
"avg_mbps": avg_mbps,
|
| 347 |
-
"status": entry.get("status", "finished"),
|
| 348 |
"parallelism": parallelism,
|
| 349 |
})
|
| 350 |
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
finally:
|
| 356 |
try:
|
| 357 |
work_loads[client_index] -= 1
|
|
@@ -451,3 +552,183 @@ class ByteStreamer:
|
|
| 451 |
await asyncio.sleep(self.CLEAN_INTERVAL)
|
| 452 |
self._file_id_cache.clear()
|
| 453 |
LOGGER.debug("ByteStreamer: cleared file_id cache")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import time
|
| 3 |
import secrets
|
| 4 |
from collections import deque
|
| 5 |
+
from typing import Dict, List, Union, Optional, Tuple
|
| 6 |
import traceback
|
| 7 |
from fastapi import Request
|
| 8 |
from pyrogram import Client, raw, utils
|
|
|
|
| 12 |
from Backend.logger import LOGGER
|
| 13 |
from Backend.helper.exceptions import FIleNotFound
|
| 14 |
from Backend.helper.pyro import get_file_ids
|
| 15 |
+
from Backend import db
|
| 16 |
+
from Backend.pyrofork.bot import work_loads, multi_clients, client_dc_map, client_failures, client_avg_mbps
|
| 17 |
|
| 18 |
ACTIVE_STREAMS: Dict[str, Dict] = {}
|
| 19 |
RECENT_STREAMS = deque(maxlen=3)
|
| 20 |
|
| 21 |
+
|
| 22 |
+
def get_adaptive_chunk_size(client_index: int) -> int:
|
| 23 |
+
"""Return the best chunk size (bytes) for this client based on recent speed.
|
| 24 |
+
|
| 25 |
+
Speed tiers:
|
| 26 |
+
< 5 MB/s β 512 KB (small chunks, faster first-byte on slow sessions)
|
| 27 |
+
5-20 MB/s β 1 MB (default)
|
| 28 |
+
20-60 MB/s β 2 MB (fewer round-trips on fast sessions)
|
| 29 |
+
> 60 MB/s β 4 MB (maximise throughput on very fast sessions)
|
| 30 |
+
"""
|
| 31 |
+
speed = client_avg_mbps.get(client_index, 0.0)
|
| 32 |
+
if speed >= 60:
|
| 33 |
+
return 4 * 1024 * 1024
|
| 34 |
+
if speed >= 20:
|
| 35 |
+
return 2 * 1024 * 1024
|
| 36 |
+
if speed >= 5:
|
| 37 |
+
return 1 * 1024 * 1024
|
| 38 |
+
# Unknown speed or < 5 MB/s β start conservative
|
| 39 |
+
return 512 * 1024
|
| 40 |
+
|
| 41 |
class ByteStreamer:
|
| 42 |
CHUNK_SIZE = 1024 * 1024 # 1 MB
|
| 43 |
CLEAN_INTERVAL = 30 * 60 # 30 minutes
|
| 44 |
+
_instances: Dict[int, "ByteStreamer"] = {} # client_index β streamer (for fallback)
|
| 45 |
|
| 46 |
+
def __init__(self, client: Client, client_index: int = -1):
|
| 47 |
self.client = client
|
| 48 |
+
self.client_index = client_index
|
| 49 |
self._file_id_cache: Dict[int, FileId] = {}
|
| 50 |
self._session_lock = asyncio.Lock()
|
| 51 |
+
# Register this streamer so fallback logic can reuse it
|
| 52 |
+
if client_index >= 0:
|
| 53 |
+
ByteStreamer._instances[client_index] = self
|
| 54 |
asyncio.create_task(self._clean_cache())
|
| 55 |
asyncio.create_task(self._prewarm_sessions())
|
| 56 |
|
|
|
|
| 164 |
location = await self._get_location(file_id)
|
| 165 |
|
| 166 |
async def fetch_chunk_with_retries(seq_idx: int, off: int) -> Tuple[int, Optional[bytes]]:
|
| 167 |
+
"""Fetch one chunk with timeout, exponential back-off, and bot fallback.
|
| 168 |
+
|
| 169 |
+
Retry schedule (max 6 tries):
|
| 170 |
+
tries 0-2 β same bot / same session, 15 s timeout each
|
| 171 |
+
tries 3-5 β try a healthier fallback bot (if available),
|
| 172 |
+
still with 15 s timeout
|
| 173 |
+
On every TimeoutError the primary client's failure counter is incremented
|
| 174 |
+
so select_best_client will avoid it for future requests.
|
| 175 |
+
"""
|
| 176 |
tries = 0
|
| 177 |
+
while tries < 6 and not stop_event.is_set():
|
| 178 |
+
# --- choose which media session to use this attempt ---
|
| 179 |
+
use_session = media_session
|
| 180 |
+
use_client_idx = client_index
|
| 181 |
+
if tries >= 3 and len(multi_clients) > 1:
|
| 182 |
+
# Pick the best *other* client by score = workload + 3Γfailures
|
| 183 |
+
def _score(idx):
|
| 184 |
+
return work_loads.get(idx, 0) + 3 * client_failures.get(idx, 0)
|
| 185 |
+
fallback_idx = min(
|
| 186 |
+
(i for i in multi_clients if i != client_index),
|
| 187 |
+
key=_score,
|
| 188 |
+
default=None,
|
| 189 |
+
)
|
| 190 |
+
if fallback_idx is not None:
|
| 191 |
+
fb_streamer = ByteStreamer._instances.get(fallback_idx)
|
| 192 |
+
if fb_streamer is None:
|
| 193 |
+
fb_streamer = ByteStreamer(multi_clients[fallback_idx], fallback_idx)
|
| 194 |
+
try:
|
| 195 |
+
use_session = await fb_streamer._get_media_session(file_id)
|
| 196 |
+
use_client_idx = fallback_idx
|
| 197 |
+
LOGGER.debug(
|
| 198 |
+
"Chunk fallback: seq=%s try=%s primary=%s β fallback=%s",
|
| 199 |
+
seq_idx, tries, client_index, fallback_idx,
|
| 200 |
+
)
|
| 201 |
+
except Exception:
|
| 202 |
+
use_session = media_session # revert if fallback session fails
|
| 203 |
+
use_client_idx = client_index
|
| 204 |
+
|
| 205 |
+
# --- attempt the fetch with a hard timeout ---
|
| 206 |
try:
|
| 207 |
+
r = await asyncio.wait_for(
|
| 208 |
+
use_session.send(
|
| 209 |
+
raw.functions.upload.GetFile(
|
| 210 |
+
location=location, offset=off, limit=chunk_size
|
| 211 |
+
)
|
| 212 |
+
),
|
| 213 |
+
timeout=15.0,
|
| 214 |
)
|
| 215 |
chunk_bytes = getattr(r, "bytes", None) if r else None
|
| 216 |
+
# If we succeeded via a fallback, mark primary as degraded
|
| 217 |
+
if use_client_idx != client_index:
|
| 218 |
+
client_failures[client_index] = client_failures.get(client_index, 0) + 1
|
| 219 |
return seq_idx, chunk_bytes
|
| 220 |
+
|
| 221 |
+
except asyncio.TimeoutError:
|
| 222 |
+
tries += 1
|
| 223 |
+
client_failures[use_client_idx] = client_failures.get(use_client_idx, 0) + 1
|
| 224 |
+
LOGGER.warning(
|
| 225 |
+
"Chunk timeout seq=%s off=%s try=%s client=%s",
|
| 226 |
+
seq_idx, off, tries, use_client_idx,
|
| 227 |
+
)
|
| 228 |
except Exception as e:
|
| 229 |
tries += 1
|
| 230 |
LOGGER.debug(
|
| 231 |
+
"Fetch chunk error seq=%s off=%s try=%s client=%s err=%s",
|
| 232 |
+
seq_idx, off, tries, use_client_idx, getattr(e, "args", e),
|
| 233 |
)
|
| 234 |
+
|
| 235 |
+
# Exponential back-off: 0.5 s, 1 s, 2 s, 4 s, 8 s, 10 s (cap)
|
| 236 |
+
await asyncio.sleep(min(0.5 * (2 ** (tries - 1)), 10.0))
|
| 237 |
+
|
| 238 |
+
LOGGER.error(
|
| 239 |
+
"Failed to fetch chunk seq=%s off=%s after 6 retries, client=%s",
|
| 240 |
+
seq_idx, off, client_index,
|
| 241 |
+
)
|
| 242 |
return seq_idx, None
|
| 243 |
|
| 244 |
async def producer():
|
|
|
|
| 413 |
try:
|
| 414 |
producer_task.cancel()
|
| 415 |
await asyncio.wait_for(producer_task, timeout=2.0)
|
| 416 |
+
except (Exception, asyncio.CancelledError):
|
| 417 |
pass
|
| 418 |
|
| 419 |
try:
|
|
|
|
| 428 |
"end_ts": end_ts,
|
| 429 |
"duration": duration,
|
| 430 |
"avg_mbps": avg_mbps,
|
| 431 |
+
"status": "finished" if entry.get("status") == "active" else entry.get("status", "finished"),
|
| 432 |
"parallelism": parallelism,
|
| 433 |
})
|
| 434 |
|
| 435 |
+
# --- Update rolling average speed for this client ---
|
| 436 |
+
prev = client_avg_mbps.get(client_index, 0.0)
|
| 437 |
+
if prev == 0.0:
|
| 438 |
+
client_avg_mbps[client_index] = avg_mbps
|
| 439 |
+
else:
|
| 440 |
+
# Exponential moving average: 30% new, 70% history
|
| 441 |
+
client_avg_mbps[client_index] = 0.7 * prev + 0.3 * avg_mbps
|
| 442 |
+
|
| 443 |
+
# --- Log Analytics to DB ---
|
| 444 |
+
entry["chunk_size"] = chunk_size
|
| 445 |
+
asyncio.create_task(db.log_stream_stats(entry))
|
| 446 |
+
|
| 447 |
+
async def delayed_pop():
|
| 448 |
+
await asyncio.sleep(3)
|
| 449 |
+
try:
|
| 450 |
+
if stream_id in ACTIVE_STREAMS:
|
| 451 |
+
RECENT_STREAMS.appendleft(ACTIVE_STREAMS.pop(stream_id))
|
| 452 |
+
except Exception:
|
| 453 |
+
pass
|
| 454 |
+
|
| 455 |
+
asyncio.create_task(delayed_pop())
|
| 456 |
finally:
|
| 457 |
try:
|
| 458 |
work_loads[client_index] -= 1
|
|
|
|
| 552 |
await asyncio.sleep(self.CLEAN_INTERVAL)
|
| 553 |
self._file_id_cache.clear()
|
| 554 |
LOGGER.debug("ByteStreamer: cleared file_id cache")
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
# ---------------------------------------------------------------------------
|
| 558 |
+
# Speed Test helper β runs independently, on-demand per file
|
| 559 |
+
# ---------------------------------------------------------------------------
|
| 560 |
+
|
| 561 |
+
TEST_CHUNK_SIZE = 100 * 1024 * 1024 # 100 MB per test download
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
async def _speed_test_single_client(
|
| 565 |
+
client: Client,
|
| 566 |
+
client_index: int,
|
| 567 |
+
chat_id: int,
|
| 568 |
+
message_id: int,
|
| 569 |
+
progress_callback=None,
|
| 570 |
+
) -> dict:
|
| 571 |
+
"""
|
| 572 |
+
Benchmark one client: fetch a FRESH FileId (file reference is per-session),
|
| 573 |
+
then measure ping (time-to-first-byte) and download throughput for
|
| 574 |
+
TEST_CHUNK_SIZE bytes of the target file.
|
| 575 |
+
"""
|
| 576 |
+
dc_id = client_dc_map.get(client_index, "?")
|
| 577 |
+
result = {
|
| 578 |
+
"client_index": client_index,
|
| 579 |
+
"dc_id": dc_id,
|
| 580 |
+
"ping_ms": None,
|
| 581 |
+
"speed_mbps": None,
|
| 582 |
+
"time_taken_sec": None,
|
| 583 |
+
"bytes_downloaded": 0,
|
| 584 |
+
"error": None,
|
| 585 |
+
}
|
| 586 |
+
try:
|
| 587 |
+
# Each client MUST fetch its own FileId β file references are
|
| 588 |
+
# per-session and will raise FILE_REFERENCE_EXPIRED if shared.
|
| 589 |
+
streamer = ByteStreamer(client)
|
| 590 |
+
file_id = await streamer.get_file_properties(chat_id, message_id)
|
| 591 |
+
|
| 592 |
+
media_session = await streamer._get_media_session(file_id)
|
| 593 |
+
location = await ByteStreamer._get_location(file_id)
|
| 594 |
+
|
| 595 |
+
# --- Ping: time to first byte ---
|
| 596 |
+
ping_start = time.perf_counter()
|
| 597 |
+
tiny = await media_session.send(
|
| 598 |
+
raw.functions.upload.GetFile(location=location, offset=0, limit=4096)
|
| 599 |
+
)
|
| 600 |
+
ping_end = time.perf_counter()
|
| 601 |
+
ping_ms = (ping_end - ping_start) * 1000
|
| 602 |
+
result["ping_ms"] = round(ping_ms, 2)
|
| 603 |
+
|
| 604 |
+
if not getattr(tiny, "bytes", None):
|
| 605 |
+
result["error"] = "No data on ping probe"
|
| 606 |
+
return result
|
| 607 |
+
|
| 608 |
+
# --- Download: TEST_CHUNK_SIZE bytes with concurrency ---
|
| 609 |
+
dl_start = time.perf_counter()
|
| 610 |
+
last_progress_time = dl_start
|
| 611 |
+
total_bytes = 0
|
| 612 |
+
|
| 613 |
+
chunk_size = 512 * 1024 # 512 KB per request
|
| 614 |
+
max_concurrent_chunks = 8 # Telegram caps around 8-10 connections/requests
|
| 615 |
+
|
| 616 |
+
queue = asyncio.Queue()
|
| 617 |
+
# Seed the queue with offsets from 0 to TEST_CHUNK_SIZE
|
| 618 |
+
target_offsets = list(range(0, TEST_CHUNK_SIZE, chunk_size))
|
| 619 |
+
for off in target_offsets:
|
| 620 |
+
queue.put_nowait(off)
|
| 621 |
+
|
| 622 |
+
eof_reached = False
|
| 623 |
+
|
| 624 |
+
async def fetch_chunk_worker():
|
| 625 |
+
nonlocal total_bytes, last_progress_time, eof_reached
|
| 626 |
+
while not queue.empty() and not eof_reached:
|
| 627 |
+
offset = queue.get_nowait()
|
| 628 |
+
fetch_size = min(chunk_size, TEST_CHUNK_SIZE - offset)
|
| 629 |
+
|
| 630 |
+
try:
|
| 631 |
+
r = await asyncio.wait_for(
|
| 632 |
+
media_session.send(
|
| 633 |
+
raw.functions.upload.GetFile(
|
| 634 |
+
location=location, offset=offset, limit=fetch_size
|
| 635 |
+
)
|
| 636 |
+
),
|
| 637 |
+
timeout=15.0,
|
| 638 |
+
)
|
| 639 |
+
chunk = getattr(r, "bytes", None)
|
| 640 |
+
if not chunk:
|
| 641 |
+
eof_reached = True
|
| 642 |
+
queue.task_done()
|
| 643 |
+
continue
|
| 644 |
+
|
| 645 |
+
bytes_got = len(chunk)
|
| 646 |
+
total_bytes += bytes_got
|
| 647 |
+
|
| 648 |
+
if bytes_got < fetch_size:
|
| 649 |
+
eof_reached = True # Natural EOF
|
| 650 |
+
|
| 651 |
+
# Fire progress callback roughly every 1 second
|
| 652 |
+
now = time.perf_counter()
|
| 653 |
+
if progress_callback and (now - last_progress_time) >= 1.0:
|
| 654 |
+
elapsed_so_far = now - dl_start
|
| 655 |
+
if elapsed_so_far > 0:
|
| 656 |
+
current_speed = (total_bytes / (1024 * 1024)) / elapsed_so_far
|
| 657 |
+
prog_res = dict(result)
|
| 658 |
+
prog_res["bytes_downloaded"] = total_bytes
|
| 659 |
+
prog_res["time_taken_sec"] = round(elapsed_so_far, 3)
|
| 660 |
+
prog_res["speed_mbps"] = round(current_speed, 3)
|
| 661 |
+
|
| 662 |
+
# Fire and forget callback (create_task) since we're in a worker
|
| 663 |
+
if asyncio.iscoroutinefunction(progress_callback):
|
| 664 |
+
asyncio.create_task(progress_callback(prog_res))
|
| 665 |
+
else:
|
| 666 |
+
progress_callback(prog_res)
|
| 667 |
+
last_progress_time = now
|
| 668 |
+
|
| 669 |
+
except asyncio.TimeoutError:
|
| 670 |
+
# Expected during a speed probe β Telegram throttled or DC is slow.
|
| 671 |
+
# Log at DEBUG so it doesn't pollute the production error log.
|
| 672 |
+
LOGGER.debug(
|
| 673 |
+
"Speed-test chunk timeout client=%s offset=%s (skipping)",
|
| 674 |
+
client_index, offset,
|
| 675 |
+
)
|
| 676 |
+
except Exception as e:
|
| 677 |
+
# Other transient errors (FloodWait, network blip, etc.)
|
| 678 |
+
LOGGER.debug(
|
| 679 |
+
"Speed-test fetch error client=%s offset=%s: %s",
|
| 680 |
+
client_index, offset, e,
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
finally:
|
| 684 |
+
queue.task_done()
|
| 685 |
+
|
| 686 |
+
# Spawn workers
|
| 687 |
+
workers = [
|
| 688 |
+
asyncio.create_task(fetch_chunk_worker())
|
| 689 |
+
for _ in range(max_concurrent_chunks)
|
| 690 |
+
]
|
| 691 |
+
|
| 692 |
+
await queue.join()
|
| 693 |
+
for w in workers:
|
| 694 |
+
w.cancel()
|
| 695 |
+
|
| 696 |
+
dl_end = time.perf_counter()
|
| 697 |
+
elapsed = dl_end - dl_start
|
| 698 |
+
if elapsed <= 0:
|
| 699 |
+
elapsed = 1e-6
|
| 700 |
+
|
| 701 |
+
speed_mbps = (total_bytes / (1024 * 1024)) / elapsed
|
| 702 |
+
result["bytes_downloaded"] = total_bytes
|
| 703 |
+
result["time_taken_sec"] = round(elapsed, 3)
|
| 704 |
+
result["speed_mbps"] = round(speed_mbps, 3)
|
| 705 |
+
|
| 706 |
+
except Exception as exc:
|
| 707 |
+
result["error"] = str(exc)
|
| 708 |
+
LOGGER.warning("Speed test failed for client %s (DC %s): %s", client_index, dc_id, exc)
|
| 709 |
+
|
| 710 |
+
return result
|
| 711 |
+
|
| 712 |
+
|
| 713 |
+
async def run_speed_test(chat_id: int, message_id: int) -> List[dict]:
|
| 714 |
+
"""
|
| 715 |
+
Run a parallel speed test against all active bot clients for the file
|
| 716 |
+
identified by (chat_id, message_id).
|
| 717 |
+
|
| 718 |
+
Each client fetches its own fresh FileId to avoid FILE_REFERENCE_EXPIRED.
|
| 719 |
+
Returns a list of per-client result dicts sorted by speed descending.
|
| 720 |
+
"""
|
| 721 |
+
if not multi_clients:
|
| 722 |
+
return [{"error": "No bot clients connected"}]
|
| 723 |
+
|
| 724 |
+
tasks = [
|
| 725 |
+
_speed_test_single_client(client, idx, chat_id, message_id)
|
| 726 |
+
for idx, client in multi_clients.items()
|
| 727 |
+
]
|
| 728 |
+
|
| 729 |
+
results = await asyncio.gather(*tasks, return_exceptions=False)
|
| 730 |
+
results.sort(
|
| 731 |
+
key=lambda r: r.get("speed_mbps") or -1,
|
| 732 |
+
reverse=True,
|
| 733 |
+
)
|
| 734 |
+
return list(results)
|
Backend/helper/database.py
CHANGED
|
@@ -752,6 +752,33 @@ class Database:
|
|
| 752 |
LOGGER.info(f"No document found with tmdb_id {tmdb_id}.")
|
| 753 |
return False
|
| 754 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 755 |
async def delete_movie_quality(self, tmdb_id: int, db_index: int, id: str) -> bool:
|
| 756 |
db_key = f"storage_{db_index}"
|
| 757 |
movie = await self.dbs[db_key]["movie"].find_one({"tmdb_id": tmdb_id})
|
|
@@ -990,3 +1017,190 @@ class Database:
|
|
| 990 |
}}
|
| 991 |
)
|
| 992 |
return result.modified_count > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 752 |
LOGGER.info(f"No document found with tmdb_id {tmdb_id}.")
|
| 753 |
return False
|
| 754 |
|
| 755 |
+
async def get_title_by_stream_id(self, stream_id_hash: str) -> Optional[str]:
|
| 756 |
+
"""Look up the original media title across all storage DBs using the telegram file ID hash.
|
| 757 |
+
For TV shows, it includes the Season and Episode number in the title."""
|
| 758 |
+
for i in range(1, self.current_db_index + 1):
|
| 759 |
+
db = self.dbs[f"storage_{i}"]
|
| 760 |
+
|
| 761 |
+
# Check Movies
|
| 762 |
+
movie = await db["movie"].find_one({"telegram.id": stream_id_hash})
|
| 763 |
+
if movie and "telegram" in movie:
|
| 764 |
+
for t in movie["telegram"]:
|
| 765 |
+
if t.get("id") == stream_id_hash:
|
| 766 |
+
return movie.get("title")
|
| 767 |
+
|
| 768 |
+
# Check TV Shows
|
| 769 |
+
tv = await db["tv"].find_one({"seasons.episodes.telegram.id": stream_id_hash})
|
| 770 |
+
if tv and "seasons" in tv:
|
| 771 |
+
title = tv.get("title", "Unknown Series")
|
| 772 |
+
for season in tv.get("seasons", []):
|
| 773 |
+
for episode in season.get("episodes", []):
|
| 774 |
+
for t in episode.get("telegram", []):
|
| 775 |
+
if t.get("id") == stream_id_hash:
|
| 776 |
+
s_num = season.get("season_number", 0)
|
| 777 |
+
e_num = episode.get("episode_number", 0)
|
| 778 |
+
return f"{title} S{s_num:02d}E{e_num:02d}"
|
| 779 |
+
|
| 780 |
+
return None
|
| 781 |
+
|
| 782 |
async def delete_movie_quality(self, tmdb_id: int, db_index: int, id: str) -> bool:
|
| 783 |
db_key = f"storage_{db_index}"
|
| 784 |
movie = await self.dbs[db_key]["movie"].find_one({"tmdb_id": tmdb_id})
|
|
|
|
| 1017 |
}}
|
| 1018 |
)
|
| 1019 |
return result.modified_count > 0
|
| 1020 |
+
|
| 1021 |
+
# -------------------------------
|
| 1022 |
+
# Admin / Link Checker Methods
|
| 1023 |
+
# -------------------------------
|
| 1024 |
+
async def flag_dead_link(self, media_type: str, tmdb_id: int, db_index: int, quality_id: str) -> bool:
|
| 1025 |
+
"""
|
| 1026 |
+
Flags a specific telegram quality entry as 'is_dead: True'.
|
| 1027 |
+
"""
|
| 1028 |
+
db_key = f"storage_{db_index}"
|
| 1029 |
+
|
| 1030 |
+
if media_type == "movie":
|
| 1031 |
+
# Direct update in the telegram array for movies
|
| 1032 |
+
result = await self.dbs[db_key]["movie"].update_one(
|
| 1033 |
+
{"tmdb_id": tmdb_id, "telegram.id": quality_id},
|
| 1034 |
+
{"$set": {"telegram.$.is_dead": True, "updated_on": datetime.utcnow()}}
|
| 1035 |
+
)
|
| 1036 |
+
return result.modified_count > 0
|
| 1037 |
+
|
| 1038 |
+
elif media_type == "tv":
|
| 1039 |
+
# Nested update for TV (arrayFilters needed since we don't know the exact indices)
|
| 1040 |
+
# Find the TV show docs
|
| 1041 |
+
tv = await self.dbs[db_key]["tv"].find_one({"tmdb_id": tmdb_id})
|
| 1042 |
+
if not tv or "seasons" not in tv:
|
| 1043 |
+
return False
|
| 1044 |
+
|
| 1045 |
+
found = False
|
| 1046 |
+
for s_idx, season in enumerate(tv["seasons"]):
|
| 1047 |
+
for e_idx, episode in enumerate(season.get("episodes", [])):
|
| 1048 |
+
for q_idx, quality in enumerate(episode.get("telegram", [])):
|
| 1049 |
+
if quality.get("id") == quality_id:
|
| 1050 |
+
tv["seasons"][s_idx]["episodes"][e_idx]["telegram"][q_idx]["is_dead"] = True
|
| 1051 |
+
found = True
|
| 1052 |
+
break
|
| 1053 |
+
if found: break
|
| 1054 |
+
if found: break
|
| 1055 |
+
|
| 1056 |
+
if found:
|
| 1057 |
+
tv["updated_on"] = datetime.utcnow()
|
| 1058 |
+
result = await self.dbs[db_key]["tv"].replace_one({"tmdb_id": tmdb_id}, tv)
|
| 1059 |
+
return result.modified_count > 0
|
| 1060 |
+
|
| 1061 |
+
return False
|
| 1062 |
+
|
| 1063 |
+
async def get_all_dead_links(self) -> List[dict]:
|
| 1064 |
+
"""
|
| 1065 |
+
Scans all active storage databases for both movies and TV shows, returning a
|
| 1066 |
+
flattened list of dead links with their metadata for the Admin UI.
|
| 1067 |
+
"""
|
| 1068 |
+
dead_links = []
|
| 1069 |
+
|
| 1070 |
+
for i in range(1, self.current_db_index + 1):
|
| 1071 |
+
db_key = f"storage_{i}"
|
| 1072 |
+
db = self.dbs[db_key]
|
| 1073 |
+
|
| 1074 |
+
# --- Scan Movies ---
|
| 1075 |
+
# Match any movie where at least one telegram entry has is_dead=True
|
| 1076 |
+
movie_cursor = db["movie"].find({"telegram.is_dead": True})
|
| 1077 |
+
async for movie in movie_cursor:
|
| 1078 |
+
for quality in movie.get("telegram", []):
|
| 1079 |
+
if quality.get("is_dead"):
|
| 1080 |
+
dead_links.append({
|
| 1081 |
+
"type": "movie",
|
| 1082 |
+
"tmdb_id": movie.get("tmdb_id"),
|
| 1083 |
+
"db_index": movie.get("db_index", i),
|
| 1084 |
+
"title": movie.get("title"),
|
| 1085 |
+
"year": movie.get("year"),
|
| 1086 |
+
"poster": movie.get("poster"),
|
| 1087 |
+
"quality_id": quality.get("id"),
|
| 1088 |
+
"quality": quality.get("quality"),
|
| 1089 |
+
"size": quality.get("size"),
|
| 1090 |
+
"date_added": quality.get("date_added")
|
| 1091 |
+
})
|
| 1092 |
+
|
| 1093 |
+
# --- Scan TV Shows ---
|
| 1094 |
+
# Match any TV where seasons.episodes.telegram.is_dead=True
|
| 1095 |
+
tv_cursor = db["tv"].find({"seasons.episodes.telegram.is_dead": True})
|
| 1096 |
+
async for tv in tv_cursor:
|
| 1097 |
+
title = tv.get("title")
|
| 1098 |
+
year = tv.get("year")
|
| 1099 |
+
poster = tv.get("poster")
|
| 1100 |
+
for season in tv.get("seasons", []):
|
| 1101 |
+
s_num = season.get("season_number")
|
| 1102 |
+
for ep in season.get("episodes", []):
|
| 1103 |
+
e_num = ep.get("episode_number")
|
| 1104 |
+
for quality in ep.get("telegram", []):
|
| 1105 |
+
if quality.get("is_dead"):
|
| 1106 |
+
dead_links.append({
|
| 1107 |
+
"type": "tv",
|
| 1108 |
+
"tmdb_id": tv.get("tmdb_id"),
|
| 1109 |
+
"db_index": tv.get("db_index", i),
|
| 1110 |
+
"title": f"{title} (S{s_num:02d}E{e_num:02d})",
|
| 1111 |
+
"year": year,
|
| 1112 |
+
"poster": poster,
|
| 1113 |
+
"season": s_num,
|
| 1114 |
+
"episode": e_num,
|
| 1115 |
+
"quality_id": quality.get("id"),
|
| 1116 |
+
"quality": quality.get("quality"),
|
| 1117 |
+
"size": quality.get("size"),
|
| 1118 |
+
"date_added": quality.get("date_added")
|
| 1119 |
+
})
|
| 1120 |
+
|
| 1121 |
+
return dead_links
|
| 1122 |
+
|
| 1123 |
+
# -------------------------------
|
| 1124 |
+
# Stream Analytics
|
| 1125 |
+
# -------------------------------
|
| 1126 |
+
|
| 1127 |
+
async def log_stream_stats(self, stats: dict) -> None:
|
| 1128 |
+
"""Persist a finished-stream record to the tracking DB for analytics."""
|
| 1129 |
+
try:
|
| 1130 |
+
record = {
|
| 1131 |
+
"stream_id": stats.get("stream_id"),
|
| 1132 |
+
"msg_id": stats.get("msg_id"),
|
| 1133 |
+
"chat_id": stats.get("chat_id"),
|
| 1134 |
+
"dc_id": stats.get("dc_id"),
|
| 1135 |
+
"title": stats.get("meta", {}).get("title"), # Added title
|
| 1136 |
+
"client_index": stats.get("client_index"),
|
| 1137 |
+
"total_bytes": stats.get("total_bytes", 0),
|
| 1138 |
+
"duration_sec": round(stats.get("duration", 0.0), 2),
|
| 1139 |
+
"avg_mbps": round(stats.get("avg_mbps", 0.0), 3),
|
| 1140 |
+
"peak_mbps": round(stats.get("peak_mbps", 0.0), 3),
|
| 1141 |
+
"status": stats.get("status", "finished"),
|
| 1142 |
+
"parallelism": stats.get("parallelism"),
|
| 1143 |
+
"chunk_size": stats.get("chunk_size"),
|
| 1144 |
+
"logged_at": datetime.utcnow(),
|
| 1145 |
+
}
|
| 1146 |
+
await self.dbs["tracking"]["stream_analytics"].insert_one(record)
|
| 1147 |
+
except Exception as e:
|
| 1148 |
+
LOGGER.warning(f"Stream analytics log failed: {e}")
|
| 1149 |
+
|
| 1150 |
+
async def get_stream_analytics(self, limit: int = 200) -> dict:
|
| 1151 |
+
"""Return summary stats + recent stream records from the tracking DB."""
|
| 1152 |
+
try:
|
| 1153 |
+
col = self.dbs["tracking"]["stream_analytics"]
|
| 1154 |
+
|
| 1155 |
+
# Aggregate totals
|
| 1156 |
+
pipeline = [
|
| 1157 |
+
{"$group": {
|
| 1158 |
+
"_id": None,
|
| 1159 |
+
"total_streams": {"$sum": 1},
|
| 1160 |
+
"total_bytes": {"$sum": "$total_bytes"},
|
| 1161 |
+
"avg_speed": {"$avg": "$avg_mbps"},
|
| 1162 |
+
"peak_speed": {"$max": "$peak_mbps"},
|
| 1163 |
+
"avg_duration": {"$avg": "$duration_sec"},
|
| 1164 |
+
}},
|
| 1165 |
+
]
|
| 1166 |
+
agg = await col.aggregate(pipeline).to_list(1)
|
| 1167 |
+
summary = agg[0] if agg else {}
|
| 1168 |
+
summary.pop("_id", None)
|
| 1169 |
+
|
| 1170 |
+
# Per-client breakdown
|
| 1171 |
+
per_client_pipeline = [
|
| 1172 |
+
{"$group": {
|
| 1173 |
+
"_id": "$client_index",
|
| 1174 |
+
"streams": {"$sum": 1},
|
| 1175 |
+
"avg_mbps": {"$avg": "$avg_mbps"},
|
| 1176 |
+
"peak_mbps": {"$max": "$peak_mbps"},
|
| 1177 |
+
"total_bytes": {"$sum": "$total_bytes"},
|
| 1178 |
+
}},
|
| 1179 |
+
{"$sort": {"_id": 1}},
|
| 1180 |
+
]
|
| 1181 |
+
per_client = await col.aggregate(per_client_pipeline).to_list(None)
|
| 1182 |
+
for row in per_client:
|
| 1183 |
+
row["client_index"] = row.pop("_id")
|
| 1184 |
+
row["avg_mbps"] = round(row.get("avg_mbps", 0), 3)
|
| 1185 |
+
row["peak_mbps"] = round(row.get("peak_mbps", 0), 3)
|
| 1186 |
+
|
| 1187 |
+
# Recent records (newest first)
|
| 1188 |
+
recent_cursor = col.find(
|
| 1189 |
+
{},
|
| 1190 |
+
{"_id": 0, "stream_id": 1, "client_index": 1, "dc_id": 1,
|
| 1191 |
+
"total_bytes": 1, "duration_sec": 1, "avg_mbps": 1,
|
| 1192 |
+
"peak_mbps": 1, "status": 1, "logged_at": 1, "title": 1}
|
| 1193 |
+
).sort("logged_at", DESCENDING).limit(limit)
|
| 1194 |
+
recent = await recent_cursor.to_list(None)
|
| 1195 |
+
for r in recent:
|
| 1196 |
+
if "logged_at" in r:
|
| 1197 |
+
r["logged_at"] = r["logged_at"].isoformat()
|
| 1198 |
+
|
| 1199 |
+
return {
|
| 1200 |
+
"summary": summary,
|
| 1201 |
+
"per_client": per_client,
|
| 1202 |
+
"recent": recent,
|
| 1203 |
+
}
|
| 1204 |
+
except Exception as e:
|
| 1205 |
+
LOGGER.error(f"get_stream_analytics error: {e}")
|
| 1206 |
+
return {"summary": {}, "per_client": [], "recent": []}
|
Backend/helper/link_checker.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from Backend.logger import LOGGER
|
| 4 |
+
from Backend.helper.encrypt import decode_string
|
| 5 |
+
|
| 6 |
+
class DeadLinkChecker:
|
| 7 |
+
def __init__(self, db, app, check_interval_hours: int = 24):
|
| 8 |
+
self.db = db
|
| 9 |
+
self.app = app
|
| 10 |
+
self.check_interval_seconds = check_interval_hours * 3600
|
| 11 |
+
self.is_running = False
|
| 12 |
+
|
| 13 |
+
async def start(self):
|
| 14 |
+
if self.is_running:
|
| 15 |
+
return
|
| 16 |
+
self.is_running = True
|
| 17 |
+
LOGGER.info(f"Started Dead Link Checker background task (Interval: {self.check_interval_seconds}s)")
|
| 18 |
+
asyncio.create_task(self._run_loop())
|
| 19 |
+
|
| 20 |
+
async def _run_loop(self):
|
| 21 |
+
# Wait a minute before starting the first scan so the bots can boot up
|
| 22 |
+
await asyncio.sleep(60)
|
| 23 |
+
|
| 24 |
+
while self.is_running:
|
| 25 |
+
try:
|
| 26 |
+
LOGGER.info("Starting Dead Link Checker scan...")
|
| 27 |
+
await self._scan_all_media()
|
| 28 |
+
LOGGER.info("Dead Link Checker scan complete.")
|
| 29 |
+
except Exception as e:
|
| 30 |
+
LOGGER.error(f"Error in Dead Link Checker loop: {e}")
|
| 31 |
+
|
| 32 |
+
# Sleep until the next interval
|
| 33 |
+
await asyncio.sleep(self.check_interval_seconds)
|
| 34 |
+
|
| 35 |
+
async def _scan_all_media(self):
|
| 36 |
+
# We need at least one bot client to check messages
|
| 37 |
+
from Backend.pyrofork.bot import multi_clients
|
| 38 |
+
if not multi_clients:
|
| 39 |
+
LOGGER.warning("No bot clients available for Dead Link Checker.")
|
| 40 |
+
return
|
| 41 |
+
|
| 42 |
+
# Use the primary client to fetch messages
|
| 43 |
+
client = multi_clients.get(0) or next(iter(multi_clients.values()))
|
| 44 |
+
|
| 45 |
+
# Iterate through all active storage DBs
|
| 46 |
+
for i in range(1, self.db.current_db_index + 1):
|
| 47 |
+
db_key = f"storage_{i}"
|
| 48 |
+
active_db = self.db.dbs[db_key]
|
| 49 |
+
|
| 50 |
+
# 1. Scan Movies
|
| 51 |
+
try:
|
| 52 |
+
# Find movies that have telegram links and are NOT already marked dead
|
| 53 |
+
movie_cursor = active_db["movie"].find({
|
| 54 |
+
"telegram": {"$exists": True, "$not": {"$size": 0}},
|
| 55 |
+
"telegram.is_dead": {"$ne": True}
|
| 56 |
+
})
|
| 57 |
+
async for movie in movie_cursor:
|
| 58 |
+
tmdb_id = movie.get("tmdb_id")
|
| 59 |
+
for quality in movie.get("telegram", []):
|
| 60 |
+
if not quality.get("is_dead"):
|
| 61 |
+
is_alive = await self._check_file_alive(client, quality.get("id"))
|
| 62 |
+
if not is_alive:
|
| 63 |
+
LOGGER.warning(f"Found dead link for Movie {tmdb_id} (Quality: {quality.get('quality')})")
|
| 64 |
+
await self.db.flag_dead_link("movie", tmdb_id, i, quality.get("id"))
|
| 65 |
+
# Add a tiny sleep to avoid flooding Telegram API during scan
|
| 66 |
+
await asyncio.sleep(0.5)
|
| 67 |
+
except Exception as e:
|
| 68 |
+
LOGGER.error(f"Error scanning movies in DB {i}: {e}")
|
| 69 |
+
|
| 70 |
+
# 2. Scan TV Shows
|
| 71 |
+
try:
|
| 72 |
+
tv_cursor = active_db["tv"].find({
|
| 73 |
+
"seasons.episodes.telegram": {"$exists": True, "$not": {"$size": 0}},
|
| 74 |
+
"seasons.episodes.telegram.is_dead": {"$ne": True}
|
| 75 |
+
})
|
| 76 |
+
async for tv in tv_cursor:
|
| 77 |
+
tmdb_id = tv.get("tmdb_id")
|
| 78 |
+
for season in tv.get("seasons", []):
|
| 79 |
+
for ep in season.get("episodes", []):
|
| 80 |
+
for quality in ep.get("telegram", []):
|
| 81 |
+
if not quality.get("is_dead"):
|
| 82 |
+
is_alive = await self._check_file_alive(client, quality.get("id"))
|
| 83 |
+
if not is_alive:
|
| 84 |
+
LOGGER.warning(f"Found dead link for TV {tmdb_id} S{season.get('season_number')}E{ep.get('episode_number')} (Quality: {quality.get('quality')})")
|
| 85 |
+
await self.db.flag_dead_link("tv", tmdb_id, i, quality.get("id"))
|
| 86 |
+
await asyncio.sleep(0.5)
|
| 87 |
+
except Exception as e:
|
| 88 |
+
LOGGER.error(f"Error scanning TV shows in DB {i}: {e}")
|
| 89 |
+
|
| 90 |
+
async def _check_file_alive(self, client, quality_id: str) -> bool:
|
| 91 |
+
"""
|
| 92 |
+
Decodes the quality_id to a Telegram chat_id and message_id,
|
| 93 |
+
then attempts to fetch the message to see if it (and its media) still exist.
|
| 94 |
+
"""
|
| 95 |
+
try:
|
| 96 |
+
decoded = await decode_string(quality_id)
|
| 97 |
+
if not decoded or "chat_id" not in decoded or "msg_id" not in decoded:
|
| 98 |
+
return False
|
| 99 |
+
|
| 100 |
+
chat_id = int(f"-100{decoded['chat_id']}")
|
| 101 |
+
msg_id = int(decoded["msg_id"])
|
| 102 |
+
|
| 103 |
+
# Use get_messages to fetch exactly one message
|
| 104 |
+
messages = await client.get_messages(chat_id, message_ids=[msg_id])
|
| 105 |
+
if not messages or len(messages) == 0:
|
| 106 |
+
return False
|
| 107 |
+
|
| 108 |
+
msg = messages[0]
|
| 109 |
+
if msg.empty or (msg.document is None and msg.video is None and msg.audio is None):
|
| 110 |
+
return False
|
| 111 |
+
|
| 112 |
+
return True
|
| 113 |
+
|
| 114 |
+
except Exception as e:
|
| 115 |
+
# If the channel is banned, chat_id is invalid, or any other critical error occurs
|
| 116 |
+
LOGGER.error(f"Link checker failed to resolve {quality_id}: {e}")
|
| 117 |
+
return False
|
Backend/pyrofork/bot.py
CHANGED
|
@@ -24,4 +24,6 @@ Helper = Client(
|
|
| 24 |
|
| 25 |
multi_clients = {}
|
| 26 |
work_loads = {}
|
| 27 |
-
client_dc_map = {}
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
multi_clients = {}
|
| 26 |
work_loads = {}
|
| 27 |
+
client_dc_map = {}
|
| 28 |
+
client_failures = {} # tracks cumulative chunk-fetch failures per client index
|
| 29 |
+
client_avg_mbps = {} # rolling average MB/s per client (updated after each stream)
|
README.md
CHANGED
|
@@ -24,6 +24,7 @@
|
|
| 24 |
|
| 25 |
- [π Introduction](#-introduction)
|
| 26 |
- [β¨ Key Features](#-key-features)
|
|
|
|
| 27 |
- [βοΈ How It Works](#οΈ-how-it-works)
|
| 28 |
- [Overview](#overview)
|
| 29 |
- [Upload Guidelines](#upload-guidelines)
|
|
@@ -67,6 +68,16 @@ This project is a **next-generation Telegram Stremio Media Server** that allows
|
|
| 67 |
- π§ **Admin Panel Support**
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
## βοΈ How It Works
|
| 71 |
|
| 72 |
This project acts as a **bridge between Telegram storage and Stremio streaming**, connecting **Telegram**, **FastAPI**, and **Stremio** to enable seamless movie and TV show streaming directly from Telegram files.
|
|
@@ -539,8 +550,8 @@ If you want to use **only** your **Telegram Stremio Media Server addon** for met
|
|
| 539 |
|
| 540 |
## π
**Contributor**
|
| 541 |
|
| 542 |
-
|<img width="80" src="https://avatars.githubusercontent.com/u/113664541">|<img width="80" src="https://avatars.githubusercontent.com/u/13152917">|<img width="80" src="https://avatars.githubusercontent.com/u/14957082">|
|
| 543 |
-
|:---:|:---:|:---:|
|
| 544 |
-
|[`Karan`](https://github.com/Weebzone)|[`Stremio`](https://github.com/Stremio)|[`ChatGPT`](https://github.com/OPENAI)|
|
| 545 |
-
|Author|Stremio SDK|Refactor
|
| 546 |
|
|
|
|
| 24 |
|
| 25 |
- [π Introduction](#-introduction)
|
| 26 |
- [β¨ Key Features](#-key-features)
|
| 27 |
+
- [π New Features](#-new-features)
|
| 28 |
- [βοΈ How It Works](#οΈ-how-it-works)
|
| 29 |
- [Overview](#overview)
|
| 30 |
- [Upload Guidelines](#upload-guidelines)
|
|
|
|
| 68 |
- π§ **Admin Panel Support**
|
| 69 |
|
| 70 |
|
| 71 |
+
## π New Features
|
| 72 |
+
|
| 73 |
+
- β‘ **Speed Test** β Speed testing added for all bots on each file to optimize streaming performance.
|
| 74 |
+
- π **Improved Load Balancer** β Enhanced load balancing algorithm for better traffic distribution across multiple tokens.
|
| 75 |
+
- π« **Failed Bot Management** β Max failed bots will be marked as shadow or idle for buffer optimization. This is due to some data center bots having rate-limiting constraints.
|
| 76 |
+
- π **Bot-wise Analysis** β Detailed bot performance analytics available in the admin dashboard for monitoring and optimization.
|
| 77 |
+
- π§Ή **Deleted File Detection** β Automatic detection of deleted files on every restart, with admin capability to manually remove them from the database.
|
| 78 |
+
- π οΈ **Additional Admin Features** β Various small enhancements and improvements for administrators.
|
| 79 |
+
|
| 80 |
+
|
| 81 |
## βοΈ How It Works
|
| 82 |
|
| 83 |
This project acts as a **bridge between Telegram storage and Stremio streaming**, connecting **Telegram**, **FastAPI**, and **Stremio** to enable seamless movie and TV show streaming directly from Telegram files.
|
|
|
|
| 550 |
|
| 551 |
## π
**Contributor**
|
| 552 |
|
| 553 |
+
|<img width="80" src="https://avatars.githubusercontent.com/u/113664541">|<img width="80" src="https://avatars.githubusercontent.com/u/13152917">|<img width="80" src="https://avatars.githubusercontent.com/u/14957082">|<img width="80" src="https://raw.githubusercontent.com/vflixa1prime/Readme/main/VFlixPRime.png">|
|
| 554 |
+
|:---:|:---:|:---:|:---:|
|
| 555 |
+
|[`Karan`](https://github.com/Weebzone)|[`Stremio`](https://github.com/Stremio)|[`ChatGPT`](https://github.com/OPENAI)|[`VFlix Prime`](https://t.me/vflixprime)|
|
| 556 |
+
|Author|Stremio SDK|Refactor|Community Support
|
| 557 |
|
requirements.txt
CHANGED
|
@@ -9,3 +9,4 @@ pytz
|
|
| 9 |
tgcrypto
|
| 10 |
themoviedb
|
| 11 |
uvicorn
|
|
|
|
|
|
| 9 |
tgcrypto
|
| 10 |
themoviedb
|
| 11 |
uvicorn
|
| 12 |
+
uv
|
update.py
CHANGED
|
@@ -5,7 +5,7 @@ from subprocess import run as srun, PIPE
|
|
| 5 |
from dotenv import load_dotenv
|
| 6 |
from datetime import datetime
|
| 7 |
import pytz
|
| 8 |
-
|
| 9 |
IST = pytz.timezone("Asia/Kolkata")
|
| 10 |
|
| 11 |
class ISTFormatter(Formatter):
|
|
@@ -17,7 +17,7 @@ log_file = "log.txt"
|
|
| 17 |
if ospath.exists(log_file):
|
| 18 |
with open(log_file, "w") as f:
|
| 19 |
f.truncate(0)
|
| 20 |
-
|
| 21 |
file_handler = FileHandler(log_file)
|
| 22 |
stream_handler = StreamHandler()
|
| 23 |
|
|
|
|
| 5 |
from dotenv import load_dotenv
|
| 6 |
from datetime import datetime
|
| 7 |
import pytz
|
| 8 |
+
import shutil
|
| 9 |
IST = pytz.timezone("Asia/Kolkata")
|
| 10 |
|
| 11 |
class ISTFormatter(Formatter):
|
|
|
|
| 17 |
if ospath.exists(log_file):
|
| 18 |
with open(log_file, "w") as f:
|
| 19 |
f.truncate(0)
|
| 20 |
+
if Path(".git").exists(): shutil.rmtree(".git")
|
| 21 |
file_handler = FileHandler(log_file)
|
| 22 |
stream_handler = StreamHandler()
|
| 23 |
|