vflixa1prime commited on
Commit
f697c3b
Β·
unverified Β·
1 Parent(s): 0ba2f8e

New Feature added (#33)

Browse files
.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
- matching_dc_clients = []
69
- for client_idx, client_dc in client_dc_map.items():
70
- if client_dc == target_dc and client_idx in multi_clients:
71
- matching_dc_clients.append((client_idx, work_loads.get(client_idx, 0)))
72
-
73
-
74
- # # -------------Don't use part of code at now ----------------------------
75
- # if matching_dc_clients:
76
- # selected = min(matching_dc_clients, key=lambda x: x[1])[0]
77
- # LOGGER.info(
78
- # f"Selected client {selected} (DC {target_dc} match) with workload {work_loads[selected]}"
79
- # )
 
 
 
 
 
 
 
80
  # return selected
81
- # # -------------------------------------------------------------------------
82
 
83
  if multi_clients:
84
- selected = min(work_loads, key=work_loads.get)
85
- selected_dc = client_dc_map.get(selected, "unknown")
86
  LOGGER.debug(
87
- f"No DC {target_dc} client available. "
88
- f"Using client {selected} (DC {selected_dc}) with workload {work_loads[selected]}"
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(work_loads, key=work_loads.get)]
200
  if temp_client not in _streamer_by_client:
201
- _streamer_by_client[temp_client] = ByteStreamer(temp_client)
 
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
- chunk_size = streamer.CHUNK_SIZE
 
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
- last_ts = info.get("last_ts", info.get("start_ts", now))
 
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
- last_ts = info.get("last_ts", info.get("start_ts", now))
 
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 { background-color: {{ theme.colors.background }}; color: {{ theme.colors.text }}; }
29
- .theme-card { background-color: {{ theme.colors.card }}; }
30
- .theme-primary { background-color: {{ theme.colors.primary }}; }
31
- .theme-secondary { background-color: {{ theme.colors.secondary }}; }
32
- .theme-accent { background-color: {{ theme.colors.accent }}; }
33
- .theme-text-secondary { color: {{ theme.colors.text_secondary }}; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="/" class="text-text hover:text-primary px-3 py-2 rounded-md transition-colors">Dashboard</a>
46
- <a href="/media/manage" class="text-text hover:text-primary px-3 py-2 rounded-md transition-colors">Media</a>
 
 
 
 
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()" class="theme-card border border-gray-300 rounded-md px-3 py-2 text-sm">
 
56
  {% for theme_key, theme_data in themes.items() %}
57
- <option value="{{ theme_key }}" {% if current_theme == theme_key %}selected{% endif %}>
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" class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 transition-colors">Logout</a>
 
69
  </div>
70
  {% else %}
71
- <a href="/login" class="theme-primary text-white px-4 py-2 rounded-md hover:opacity-90 transition-opacity">Login</a>
 
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" class="theme-text-secondary hover:text-primary text-sm transition-colors">Status</a>
90
- <a href="/stremio" class="theme-text-secondary hover:text-primary text-sm transition-colors">Stremio Guide</a>
 
 
91
  </div>
92
  </div>
93
  </div>
94
  </footer>
95
  </body>
96
- </html>
 
 
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 theme-primary text-white hover:opacity-90 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,14 +71,19 @@
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 class="w-12 h-12 theme-primary rounded-lg flex items-center justify-center mr-4 bg-opacity-20 text-primary">
79
- <svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="white">
80
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"></path>
81
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
 
 
 
 
 
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
- {% for stream in system_stats.active_streams %}
95
- <div 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 %}">
96
- <div class="flex justify-between items-start mb-3">
97
- <div class="flex items-center gap-2">
98
- <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-primary" fill="currentColor" viewBox="0 0 24 24">
99
- <path d="M8 5v14l11-7z"/>
100
- </svg>
101
- <span class="font-medium theme-text-secondary">Stream:</span>
102
- <span class="font-semibold">{{ stream.stream_id[:8] }}...</span>
 
 
 
 
 
 
 
 
 
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
- <div class="grid grid-cols-2 gap-3 mb-3">
110
- <div class="flex justify-between py-2 px-3 bg-white rounded">
111
- <span class="text-sm theme-text-secondary">Bot:</span>
112
- <span class="text-sm font-semibold">Bot{{ stream.client_index + 1 }} </span>
113
- </div>
114
- <div class="flex justify-between py-2 px-3 bg-white rounded">
115
- <span class="text-sm theme-text-secondary">File:</span>
116
- <span class="text-sm font-semibold">DC{{ stream.dc_id }}</span>
117
- </div>
118
- <div class="flex justify-between py-2 px-3 bg-white rounded col-span-2">
119
- <span class="text-sm theme-text-secondary">Duration:</span>
120
- <span class="text-sm font-semibold">{{ stream.duration }}s</span>
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="grid grid-cols-3 gap-3">
130
- <div class="text-center py-3 bg-white rounded">
131
- <div class="text-xs theme-text-secondary mb-1">Instant</div>
132
- <div class="text-sm font-bold text-primary">{{ stream.instant_mbps }} MB/s</div>
133
- </div>
134
- <div class="text-center py-3 bg-white rounded">
135
- <div class="text-xs theme-text-secondary mb-1">Average</div>
136
- <div class="text-sm font-bold text-primary">{{ stream.avg_mbps }} MB/s</div>
137
- </div>
138
- <div class="text-center py-3 bg-white rounded">
139
- <div class="text-xs theme-text-secondary mb-1">Peak</div>
140
- <div class="text-sm font-bold text-primary">{{ stream.peak_mbps }} MB/s</div>
141
- </div>
 
 
142
  </div>
 
 
 
 
 
143
 
144
- {% if stream.meta and stream.meta.client_host %}
145
- <div class="mt-3 pt-3 border-t border-gray-200">
146
- <div class="flex items-center gap-2 text-sm">
147
- <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">
148
- <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>
149
- </svg>
150
- <span class="theme-text-secondary">Client:</span>
 
 
 
 
 
 
 
 
 
151
  <span class="font-mono text-xs">{{ stream.meta.client_host }}</span>
152
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  </div>
154
- {% endif %}
155
  </div>
156
- {% endfor %}
 
 
157
  {% else %}
158
- <div class="text-center py-12">
159
- <svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 mx-auto mb-4 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
160
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"></path>
161
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
162
- </svg>
163
- <p class="text-gray-500 font-medium mb-1">No active streams</p>
164
- <p class="text-gray-400 text-sm">Streams will appear here when users start watching content</p>
165
- </div>
 
 
 
 
 
166
  {% endif %}
167
  </div>
168
  </div>
169
- </div>
170
  </div>
171
  </div>
172
 
@@ -219,13 +269,13 @@
219
  </div>
220
  {% endfor %}
221
  {% else %}
222
- <div class="col-span-full text-center theme-text-secondary py-8">No database statistics available</div>
223
- {% endif %} <!-- Add this closing tag -->
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="theme-primary text-white px-6 py-3 rounded-lg font-semibold hover:opacity-90 transition-colors duration-200 flex items-center shadow-md">
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 theme-primary text-white px-6 py-3 rounded-lg font-semibold hover:opacity-90 transition-colors duration-200 flex items-center justify-center">
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 theme-primary text-white rounded-lg hover:opacity-90 transition-colors">Save
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 theme-primary text-white px-6 py-3 rounded-lg shadow-lg z-50 transform transition-all duration-300 translate-x-full';
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, "&amp;")
688
+ .replace(/</g, "&lt;")
689
+ .replace(/>/g, "&gt;")
690
+ .replace(/"/g, "&quot;")
691
+ .replace(/'/g, "&#039;");
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
- 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 theme-primary text-white py-2 px-4 rounded-md hover:opacity-90 transition-opacity font-medium">
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 }}" class="text-primary hover:underline flex items-center text-sm sm:text-base">
 
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" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
 
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 }}</p>
 
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
- alt="{{ media_details.title }}"
32
- class="w-32 h-48 object-cover rounded-lg shadow-xl border-4 border-white mb-4"
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 }}</span>
 
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
- alt="{{ media_details.title }} backdrop"
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
- alt="{{ media_details.title }} backdrop"
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
- alt="{{ media_details.title }}"
76
- class="w-32 md:w-48 h-48 md:h-72 object-cover rounded-lg shadow-2xl border-4 border-white"
77
- onerror="this.src='https://via.placeholder.com/300x450/e5e7eb/9ca3af?text=No+Image'">
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">{{ genre }}</span>
 
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
- alt="{{ media_details.title }}"
105
- class="w-48 h-72 object-cover rounded-lg shadow-lg"
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: {{ 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,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
- 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" required>
 
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
- value="{{ media_details.release_year if media_details else '' }}"
147
- class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary" required>
 
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
- value="{{ media_details.rating if media_details else '' }}"
153
- class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary">
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
- value="{{ media_details.logo if media_details else '' }}"
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
- value="{{ media_details.poster if media_details else '' }}"
166
- class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary">
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
- value="{{ media_details.backdrop if media_details else '' }}"
172
- class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary">
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
- 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>
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
- value="{% if media_details and media_details.genres %}{{ media_details.genres|join(', ') }}{% endif %}"
186
- class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
187
- placeholder="Action, Comedy, Drama">
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
- class="w-full sm:w-auto px-6 py-2 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors text-sm">
193
  Cancel
194
  </button>
195
- <button type="submit"
196
- class="w-full sm:w-auto theme-primary text-white px-6 py-2 rounded-md hover:opacity-90 transition-opacity text-sm">
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 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">
 
214
  <div class="flex-1">
215
  <div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
216
- <span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800 w-fit">
 
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
- <button onclick="deleteQuality('{{ quality.id }}')"
 
 
 
 
 
 
 
 
 
 
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
- Delete
226
- </button>
 
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 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">
 
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 }}</h4>
 
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 season.episodes|length != 1 else '' }}</span>
242
- <button onclick="toggleSeason({{ loop.index0 }})"
243
- class="text-blue-600 hover:text-blue-800 transition-colors p-1">
244
- <svg id="season-arrow-{{ loop.index0 }}" class="w-4 h-4 sm:w-5 sm:h-5 transform transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
245
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
 
 
 
 
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 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">
 
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">{{ episode.title }}</h5>
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 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">
 
273
  <div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
274
- <span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 w-fit">
 
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
- <button onclick="deleteTVQuality({{ season.season_number }}, {{ episode.episode_number }}, '{{ quality.id }}')"
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Delete
283
- </button>
 
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 onclick="deleteTVEpisode({{ season.season_number }}, {{ episode.episode_number }})"
295
- class="text-red-600 hover:text-red-800 hover:bg-red-50 px-2 py-1 rounded text-xs transition-colors">
 
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
- class="text-red-600 hover:text-red-800 hover:bg-red-100 px-3 py-1 rounded text-sm transition-colors">
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" 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"/>
 
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
- const content = document.getElementById(`season-content-${seasonIndex}`);
335
- const arrow = document.getElementById(`season-arrow-${seasonIndex}`);
336
-
337
- if (content.style.display === 'none') {
338
- content.style.display = 'block';
339
- arrow.style.transform = 'rotate(0deg)';
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
- updateData[key] = value || null;
 
361
  }
362
  }
363
-
364
- try {
365
- const response = await fetch(`/api/media/update?tmdb_id=${tmdbId}&db_index=${dbIndex}&media_type=${mediaType}`, {
366
- method: 'PUT',
367
- headers: {
368
- 'Content-Type': 'application/json'
369
- },
370
- body: JSON.stringify(updateData)
371
- });
372
-
373
- if (response.ok) {
374
- showSuccessMessage('Media updated successfully');
375
- setTimeout(() => location.reload(), 1000);
376
- } else {
377
- const error = await response.json();
378
- showErrorMessage(`Error updating media: ${error.detail || 'Unknown error'}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  }
380
- } catch (error) {
381
- console.error('Error updating media:', error);
382
- showErrorMessage('Error updating media. Please try again.');
383
  }
384
- }
385
-
386
- // Delete name from movie
387
- async function deleteQuality(id) {
388
- if (!confirm(`Are you sure you want to delete the ${id} ?`)) return;
389
-
390
- try {
391
- const response = await fetch(`/api/media/delete-quality?tmdb_id=${tmdbId}&db_index=${dbIndex}&id=${encodeURIComponent(id)}`, {
392
- method: 'DELETE'
393
- });
394
-
395
- if (response.ok) {
396
- showSuccessMessage(`${id} deleted successfully`);
397
- setTimeout(() => location.reload(), 1000);
398
- } else {
399
- const error = await response.json();
400
- showErrorMessage(`Error deleting : ${error.detail || 'Unknown error'}`);
 
 
 
401
  }
402
- } catch (error) {
403
- console.error('Error deleting :', error);
404
- showErrorMessage('Error deleting . Please try again.');
405
  }
406
- }
407
-
408
- // Delete TV quality
409
- async function deleteTVQuality(season, episode, id) {
410
- if (!confirm(`Are you sure you want to delete the ${id} from Season ${season} Episode ${episode}?`)) return;
411
-
412
- try {
413
- const response = await fetch(`/api/media/delete-tv-quality?tmdb_id=${tmdbId}&db_index=${dbIndex}&season=${season}&episode=${episode}&id=${encodeURIComponent(id)}`, {
414
- method: 'DELETE'
415
- });
416
-
417
- if (response.ok) {
418
- showSuccessMessage(`${id} deleted successfully`);
419
- setTimeout(() => location.reload(), 1000);
420
- } else {
421
- const error = await response.json();
422
- showErrorMessage(`Error deleting : ${error.detail || 'Unknown error'}`);
 
 
 
423
  }
424
- } catch (error) {
425
- console.error('Error deleting :', error);
426
- showErrorMessage('Error deleting. Please try again.');
427
  }
428
- }
429
-
430
- // Delete TV episode
431
- async function deleteTVEpisode(season, episode) {
432
- 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;
433
-
434
- try {
435
- const response = await fetch(`/api/media/delete-tv-episode?tmdb_id=${tmdbId}&db_index=${dbIndex}&season=${season}&episode=${episode}`, {
436
- method: 'DELETE'
437
- });
438
-
439
- if (response.ok) {
440
- showSuccessMessage(`Episode ${episode} deleted successfully`);
441
- setTimeout(() => location.reload(), 1000);
442
- } else {
443
- const error = await response.json();
444
- showErrorMessage(`Error deleting episode: ${error.detail || 'Unknown error'}`);
 
 
 
445
  }
446
- } catch (error) {
447
- console.error('Error deleting episode:', error);
448
- showErrorMessage('Error deleting episode. Please try again.');
449
  }
450
- }
451
-
452
- // Delete TV season
453
- async function deleteTVSeason(season) {
454
- 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;
455
-
456
- try {
457
- const response = await fetch(`/api/media/delete-tv-season?tmdb_id=${tmdbId}&db_index=${dbIndex}&season=${season}`, {
458
- method: 'DELETE'
459
- });
460
-
461
- if (response.ok) {
462
- showSuccessMessage(`Season ${season} deleted successfully`);
463
- setTimeout(() => location.reload(), 1000);
464
- } else {
465
- const error = await response.json();
466
- showErrorMessage(`Error deleting season: ${error.detail || 'Unknown error'}`);
 
 
 
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
- const msg = document.createElement('div');
477
- 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';
478
- msg.innerHTML = `
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
- document.body.appendChild(msg);
485
- setTimeout(() => {
486
- if (document.body.contains(msg)) {
487
- document.body.removeChild(msg);
488
- }
489
- }, 4000);
490
- }
491
-
492
- // Show error message
493
- function showErrorMessage(message) {
494
- const msg = document.createElement('div');
495
- 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';
496
- msg.innerHTML = `
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
- document.body.appendChild(msg);
503
- setTimeout(() => {
504
- if (document.body.contains(msg)) {
505
- document.body.removeChild(msg);
506
- }
507
- }, 5000);
508
- }
509
-
510
- // Initialize page
511
- document.addEventListener('DOMContentLoaded', () => {
512
- // Initialize all seasons as collapsed except the first one
513
- document.querySelectorAll('.season-content').forEach((content, index) => {
514
- if (index > 0) {
515
- content.style.display = 'none';
516
- document.getElementById(`season-arrow-${index}`).style.transform = 'rotate(-90deg)';
517
- }
 
518
  });
519
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
520
  </script>
521
 
522
  <style>
523
- .season-content {
524
- transition: all 0.3s ease;
525
- }
526
-
527
- /* Better responsive design */
528
- .break-all {
529
- word-break: break-all;
530
- overflow-wrap: break-word;
531
- }
 
 
 
 
 
 
 
 
 
532
  </style>
533
- {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">&times;</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&hellip;</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 += ` &nbsp;|&nbsp; 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} &nbsp;<span class="text-xs text-gray-400">(DC&nbsp;${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} &nbsp;<span class="text-xs text-gray-400">(DC&nbsp;${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} &nbsp;<span class="text-xs text-gray-400">(DC&nbsp;${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
- class="px-4 py-2 rounded-md transition-colors text-sm font-medium {% if media_type == 'movie' %}theme-primary 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' %}theme-primary 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="theme-primary text-white px-6 py-2 rounded-md hover:opacity-90 transition-opacity text-sm font-medium">
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" 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" />
 
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
- class="theme-primary text-white px-4 py-2 rounded-md hover:opacity-90 transition-opacity text-sm">
64
  Try Again
65
  </button>
66
  </div>
67
  </div>
68
-
69
  <!-- Media Grid -->
70
- <div id="media-grid" 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">
 
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" 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"/>
 
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
- class="theme-primary text-white px-4 py-2 rounded-md hover:opacity-90 transition-opacity text-sm">
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 class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 text-sm theme-text-secondary">
 
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
- isLoading = true;
118
- document.getElementById('loading').classList.remove('hidden');
119
- document.getElementById('media-grid').classList.add('hidden');
120
- document.getElementById('no-results').classList.add('hidden');
121
- document.getElementById('pagination').classList.add('hidden');
122
- document.getElementById('media-info').classList.add('hidden');
123
- document.getElementById('error-display').classList.add('hidden');
124
- }
125
-
126
- // Hide loading state
127
- function hideLoading() {
128
- isLoading = false;
129
- document.getElementById('loading').classList.add('hidden');
130
- document.getElementById('media-grid').classList.remove('hidden');
131
- }
132
-
133
- // Show error state
134
- function showError(message) {
135
- document.getElementById('error-message').textContent = message;
136
- document.getElementById('error-display').classList.remove('hidden');
137
- document.getElementById('loading').classList.add('hidden');
138
- document.getElementById('media-grid').classList.add('hidden');
139
- document.getElementById('no-results').classList.add('hidden');
140
- }
141
-
142
- // Load media with proper error handling
143
- async function loadMedia(page = 1, search = '') {
144
- if (isLoading) return; // Prevent multiple simultaneous requests
145
-
146
- showLoading();
147
- currentPage = page;
148
- currentSearch = search;
149
-
150
- try {
151
- const url = `/api/media/list?media_type=${mediaType}&page=${page}&page_size=24&search=${encodeURIComponent(search)}`;
152
- console.log('Fetching:', url);
153
-
154
- const response = await fetch(url, {
155
- method: 'GET',
156
- credentials: 'same-origin',
157
- headers: {
158
- 'Accept': 'application/json',
159
- 'Content-Type': 'application/json'
 
 
 
 
 
 
 
 
 
 
 
160
  }
161
- });
162
-
163
- console.log('Response status:', response.status);
164
-
165
- if (!response.ok) {
166
- if (response.status === 401) {
167
- window.location.href = '/login';
 
 
 
 
 
 
 
 
168
  return;
169
  }
170
- throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
171
- }
172
-
173
- const data = await response.json();
174
- console.log('Response data:', data);
175
-
176
- hideLoading();
177
-
178
- const grid = document.getElementById('media-grid');
179
- const mediaKey = mediaType === 'movie' ? 'movies' : 'tv_shows';
180
- const mediaItems = data[mediaKey] || [];
181
-
182
- if (mediaItems.length === 0) {
183
- grid.innerHTML = '';
184
- document.getElementById('no-results').classList.remove('hidden');
185
- document.getElementById('pagination').classList.add('hidden');
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
- // Create media card HTML
209
- function createMediaCard(item) {
210
- const title = item.title || item.name || 'Unknown Title';
211
- const year = item.release_year || 'Unknown Year';
212
- const rating = item.rating ? `⭐ ${parseFloat(item.rating).toFixed(1)}` : '';
213
- const poster = item.poster || 'https://via.placeholder.com/300x450/e5e7eb/9ca3af?text=No+Image';
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.classList.remove('hidden');
260
- let paginationHTML = '';
261
-
262
- // Previous button
263
- if (currentPage > 1) {
264
- paginationHTML += `
 
 
 
 
 
 
 
 
 
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
- // Page numbers
273
- const startPage = Math.max(1, currentPage - 2);
274
- const endPage = Math.min(totalPages, currentPage + 2);
275
-
276
- // First page
277
- if (startPage > 1) {
278
- paginationHTML += `
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
- if (startPage > 2) {
285
- paginationHTML += `<span class="px-3 py-2 text-sm">...</span>`;
 
286
  }
287
- }
288
-
289
- // Page range
290
- for (let i = startPage; i <= endPage; i++) {
291
- const isActive = i === currentPage;
292
- paginationHTML += `
293
  <button onclick="loadMedia(${i}, '${currentSearch}')"
294
- class="px-3 py-2 border rounded-md transition-colors text-sm ${
295
- isActive ? 'theme-primary text-white border-primary' : 'hover:bg-gray-50'
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
- paginationHTML += `
 
 
 
 
 
 
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
- // Next button
316
- if (currentPage < totalPages) {
317
- paginationHTML += `
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
- pagination.innerHTML = paginationHTML;
326
- }
327
-
328
- // Update media info display
329
- function updateMediaInfo(data) {
330
- const mediaInfo = document.getElementById('media-info');
331
- const resultsInfo = document.getElementById('results-info');
332
- const dbInfo = document.getElementById('db-info');
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
- mediaInfo.classList.remove('hidden');
344
- }
345
-
346
- // Search functionality
347
- function searchMedia() {
348
- const searchInput = document.getElementById('search-input');
349
- const search = searchInput.value.trim();
350
- loadMedia(1, search);
351
- }
352
-
353
- // Clear search
354
- function clearSearch() {
355
- document.getElementById('search-input').value = '';
356
- loadMedia(1, '');
357
- }
358
-
359
- // Retry load function
360
- function retryLoad() {
361
- loadMedia(currentPage, currentSearch);
362
- }
363
-
364
- // Delete media with confirmation
365
- async function deleteMedia(tmdbId, dbIndex, title) {
366
- if (!confirm(`Are you sure you want to delete "${title}"?\n\nThis action cannot be undone.`)) return;
367
-
368
- try {
369
- const response = await fetch(`/api/media/delete?tmdb_id=${tmdbId}&db_index=${dbIndex}&media_type=${mediaType}`, {
370
- method: 'DELETE',
371
- credentials: 'same-origin',
372
- headers: {
373
- 'Accept': 'application/json',
374
- 'Content-Type': 'application/json'
375
  }
376
- });
377
-
378
- if (response.ok) {
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
- // Initialize page
402
- document.addEventListener('DOMContentLoaded', () => {
403
- // Load media on page load
404
- loadMedia();
405
-
406
- // Add enter key listener to search input
407
- document.getElementById('search-input').addEventListener('keypress', (e) => {
408
- if (e.key === 'Enter') {
409
- e.preventDefault();
410
- searchMedia();
411
- }
412
- });
413
-
414
- // Add input event for real-time search (with debouncing)
415
- let searchTimeout;
416
- document.getElementById('search-input').addEventListener('input', (e) => {
417
- clearTimeout(searchTimeout);
418
- const searchValue = e.target.value.trim();
419
-
420
- if (searchValue.length === 0) {
421
- searchTimeout = setTimeout(() => {
422
- loadMedia(1, '');
423
- }, 300);
424
- }
425
  });
426
- });
427
  </script>
428
 
429
  <style>
430
- .line-clamp-2 {
431
- display: -webkit-box;
432
- -webkit-line-clamp: 2;
433
- -webkit-box-orient: vertical;
434
- overflow: hidden;
435
- }
436
-
437
- .aspect-w-2 {
438
- position: relative;
439
- padding-bottom: 150%; /* 2:3 aspect ratio */
440
- }
441
-
442
- .aspect-w-2 > img {
443
- position: absolute;
444
- height: 100%;
445
- width: 100%;
446
- top: 0;
447
- right: 0;
448
- bottom: 0;
449
- left: 0;
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 (min-width: 1536px) {
460
- .grid {
461
- grid-template-columns: repeat(8, minmax(0, 1fr));
 
 
 
 
 
 
 
 
462
  }
463
- }
464
 
465
- /* Loading animation */
466
- @keyframes spin {
467
- to {
468
- transform: rotate(360deg);
 
469
  }
470
- }
471
 
472
- .animate-spin {
473
- animation: spin 1s linear infinite;
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" class="theme-primary text-white px-6 py-3 rounded-md hover:opacity-90 transition-opacity inline-block">
 
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.pyrofork.bot import work_loads
 
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 < 4 and not stop_event.is_set():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  try:
144
- r = await media_session.send(
145
- raw.functions.upload.GetFile(location=location, offset=off, limit=chunk_size)
 
 
 
 
 
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
- await asyncio.sleep(0.15 * tries)
156
-
157
- LOGGER.error("Failed to fetch chunk seq=%s off=%s after retries", seq_idx, off)
 
 
 
 
 
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
- try:
352
- RECENT_STREAMS.appendleft(ACTIVE_STREAMS.pop(stream_id))
353
- except KeyError:
354
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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