Hermes Agent commited on
Commit
28c0543
·
1 Parent(s): fa9fa8f

fix(streaming): resolve Nezha 2 playback source error, token expiry, and client failover

Browse files
apex/apex_stream/stream.py CHANGED
@@ -279,16 +279,13 @@ class StreamDriver:
279
  await self.pool.note_success(active_client.client_id)
280
  except asyncio.CancelledError:
281
  raise
282
- except (RangeNotSatisfiable, FileNotFoundError, ValueError, StreamExhausted):
283
- # Deterministic source-level failures: missing file, empty file,
284
- # or a file shorter than its recorded size. Retrying the same
285
- # plan is futile and a healthy client can provoke these — never
286
- # trip client cooldown for them.
287
  raise
288
  except NoClientAvailable:
289
  await asyncio.sleep(0.05)
290
- except SourceError as exc:
291
- # e.g. FILE_REFERENCE_EXPIRED whose per-client refresh failed.
292
  # Fail the run over to a DIFFERENT client (its own refresh may
293
  # succeed); the client that reported it is healthy, so take it
294
  # out of the loop WITHOUT touching its cooldown bookkeeping.
 
279
  await self.pool.note_success(active_client.client_id)
280
  except asyncio.CancelledError:
281
  raise
282
+ except (RangeNotSatisfiable, FileNotFoundError, ValueError):
283
+ # Deterministic source-level failures: missing file, empty file.
 
 
 
284
  raise
285
  except NoClientAvailable:
286
  await asyncio.sleep(0.05)
287
+ except (SourceError, StreamExhausted) as exc:
288
+ # e.g. FILE_REFERENCE_EXPIRED or short run whose per-client refresh failed.
289
  # Fail the run over to a DIFFERENT client (its own refresh may
290
  # succeed); the client that reported it is healthy, so take it
291
  # out of the loop WITHOUT touching its cooldown bookkeeping.
apex/combined-entrypoint.sh CHANGED
@@ -37,7 +37,7 @@ JELLYFIN_OPTS="--datadir $LOCAL_ROOT/data --configdir $LOCAL_ROOT/config --cache
37
 
38
  restore_jellyfin_local() {
39
  echo "[JELLYFIN] Preparing local datadir at $LOCAL_ROOT ..."
40
- mkdir -p "$LOCAL_ROOT/data/data" "$LOCAL_ROOT/config" "$LOCAL_ROOT/cache" "$LOCAL_ROOT/log"
41
  # Restore durable data/config snapshot from the persistent volume if present.
42
  # Exclude the metadata tree: it is handled via snapshot below and its old
43
  # absolute DB path is redirected to the fast local datadir.
 
37
 
38
  restore_jellyfin_local() {
39
  echo "[JELLYFIN] Preparing local datadir at $LOCAL_ROOT ..."
40
+ mkdir -p "$LOCAL_ROOT/data/data" "$LOCAL_ROOT/config" "$LOCAL_ROOT/cache/transcodes" "$LOCAL_ROOT/log"
41
  # Restore durable data/config snapshot from the persistent volume if present.
42
  # Exclude the metadata tree: it is handled via snapshot below and its old
43
  # absolute DB path is redirected to the fast local datadir.
apex/tg_streamer.py CHANGED
@@ -135,7 +135,7 @@ PUBLIC_BASE_URL = get_env(
135
  )
136
  WEBHOOK_PATH = "/tg-stream/telegram-webhook"
137
 
138
- DATA_DIR = "/data/jellyfin"
139
  if not USER_SESSION_STRING:
140
  _session_file = os.path.join(DATA_DIR, "user_session.string")
141
  if os.path.exists(_session_file):
@@ -152,8 +152,11 @@ CACHE_FILE = os.path.join(DATA_DIR, "file_ids.json")
152
  CONFIG_FILE = os.path.join(DATA_DIR, "channel_config.json")
153
  CATALOG_FILE = os.path.join(DATA_DIR, "media_catalog.sqlite")
154
 
155
- os.makedirs(MOVIES_DIR, exist_ok=True)
156
- os.makedirs(SHOWS_DIR, exist_ok=True)
 
 
 
157
  MEDIA_CATALOG = MediaCatalog(CATALOG_FILE)
158
 
159
  # Persistent mapping: message_id -> {file_id, chat_id, file_size, title, is_tv, show_name, season, episode}
@@ -457,6 +460,14 @@ async def _warm_channel_peer(client, chat_id):
457
  return False
458
  if not (isinstance(chat_id, int) and chat_id < 0):
459
  return False
 
 
 
 
 
 
 
 
460
  channel_id = -chat_id - 1000000000000 # peer_id = MAX_CHANNEL_ID - channel_id
461
  if channel_id <= 0:
462
  return False
@@ -493,15 +504,16 @@ async def _refresh_file_reference(client, chat_id, message_id):
493
  logger.warning(
494
  f"[REFRESH] get_messages({chat_id}, {message_id}) failed on {getattr(c, 'name', '?')}: {type(e).__name__}: {e}"
495
  )
496
- if not (isinstance(e, ValueError) and await _warm_channel_peer(c, chat_id)):
497
- return None
498
- try:
499
- msg = await c.get_messages(chat_id, message_id)
500
- except Exception as e2:
501
- logger.warning(
502
- f"[REFRESH] get_messages({chat_id}, {message_id}) retry failed on {getattr(c, 'name', '?')}: "
503
- f"{type(e2).__name__}: {e2}"
504
- )
 
505
  return None
506
  m = (
507
  (msg.video or msg.document or msg.audio or msg.animation)
@@ -556,16 +568,20 @@ def _is_expired_reference(exc):
556
  the concrete class when importable, then the RPC error ID, class name, raw
557
  value, or rendered text via an underscore-insensitive comparison.
558
  """
559
- if not isinstance(exc, RPCError):
560
  return False
561
  if FileReferenceExpired is not None and isinstance(exc, FileReferenceExpired):
562
  return True
563
- probes = (
564
  getattr(exc, "ID", None),
565
  type(exc).__name__,
566
  getattr(exc, "value", None),
567
  str(exc),
568
- )
 
 
 
 
569
  for probe in probes:
570
  if not probe:
571
  continue
@@ -2676,15 +2692,7 @@ def _create_strm_file_sync(entry):
2676
  try:
2677
  with open(strm_path, "r", encoding="utf-8") as f:
2678
  existing_content = f.read().strip()
2679
- is_valid_target = False
2680
- if chat_id and f"/{chat_id}/{msg_id}" in existing_content:
2681
- is_valid_target = True
2682
- elif not chat_id and file_id and str(file_id) in existing_content:
2683
- is_valid_target = True
2684
- elif existing_content.split("?")[0] == stream_url.split("?")[0]:
2685
- is_valid_target = True
2686
-
2687
- if is_valid_target:
2688
  entry["target_dir"] = target_dir
2689
  entry["show_root"] = show_root
2690
  entry["strm_base"] = strm_base_name
@@ -3156,11 +3164,7 @@ def _stream_signature(chat_id, message_id, expires):
3156
 
3157
 
3158
  def _stream_url(chat_id, message_id):
3159
- base = f"http://127.0.0.1:{PORT}/stream/{chat_id}/{message_id}/video.mp4"
3160
- if not STREAM_SIGNING_KEY:
3161
- return base
3162
- expires = int(time.time()) + 86400
3163
- return f"{base}?expires={expires}&sig={_stream_signature(chat_id, message_id, expires)}"
3164
 
3165
 
3166
  @routes.post("/review/{chat_id}/{message_id}/approve")
@@ -4157,7 +4161,13 @@ async def stream_file(request):
4157
  return web.Response(status=400, text="message_id is required.")
4158
 
4159
  chat_id, message_id = source
4160
- if STREAM_SIGNING_KEY:
 
 
 
 
 
 
4161
  try:
4162
  expires = int(request.query.get("expires", "0"))
4163
  signature = request.query.get("sig", "")
@@ -4567,6 +4577,12 @@ async def restore_cached_strm_files():
4567
  os.remove(path)
4568
  logger.info(f'Removed obsolete Go STRM: {path}')
4569
  removed += 1
 
 
 
 
 
 
4570
  except Exception:
4571
  pass
4572
  if removed:
 
135
  )
136
  WEBHOOK_PATH = "/tg-stream/telegram-webhook"
137
 
138
+ DATA_DIR = os.environ.get("APEX_DATA_DIR", "/data/jellyfin")
139
  if not USER_SESSION_STRING:
140
  _session_file = os.path.join(DATA_DIR, "user_session.string")
141
  if os.path.exists(_session_file):
 
152
  CONFIG_FILE = os.path.join(DATA_DIR, "channel_config.json")
153
  CATALOG_FILE = os.path.join(DATA_DIR, "media_catalog.sqlite")
154
 
155
+ try:
156
+ os.makedirs(MOVIES_DIR, exist_ok=True)
157
+ os.makedirs(SHOWS_DIR, exist_ok=True)
158
+ except Exception as _e:
159
+ logger.warning(f"[INIT] Could not create media directories: {_e}")
160
  MEDIA_CATALOG = MediaCatalog(CATALOG_FILE)
161
 
162
  # Persistent mapping: message_id -> {file_id, chat_id, file_size, title, is_tv, show_name, season, episode}
 
460
  return False
461
  if not (isinstance(chat_id, int) and chat_id < 0):
462
  return False
463
+ # 1. Try Pyrogram's native get_chat peer warming
464
+ try:
465
+ chat = await client.get_chat(chat_id)
466
+ if chat:
467
+ return True
468
+ except Exception:
469
+ pass
470
+ # 2. Raw channels.GetChannels warming
471
  channel_id = -chat_id - 1000000000000 # peer_id = MAX_CHANNEL_ID - channel_id
472
  if channel_id <= 0:
473
  return False
 
504
  logger.warning(
505
  f"[REFRESH] get_messages({chat_id}, {message_id}) failed on {getattr(c, 'name', '?')}: {type(e).__name__}: {e}"
506
  )
507
+ if await _warm_channel_peer(c, chat_id):
508
+ try:
509
+ msg = await c.get_messages(chat_id, message_id)
510
+ except Exception as e2:
511
+ logger.warning(
512
+ f"[REFRESH] get_messages({chat_id}, {message_id}) retry failed on {getattr(c, 'name', '?')}: "
513
+ f"{type(e2).__name__}: {e2}"
514
+ )
515
+ return None
516
+ else:
517
  return None
518
  m = (
519
  (msg.video or msg.document or msg.audio or msg.animation)
 
568
  the concrete class when importable, then the RPC error ID, class name, raw
569
  value, or rendered text via an underscore-insensitive comparison.
570
  """
571
+ if exc is None:
572
  return False
573
  if FileReferenceExpired is not None and isinstance(exc, FileReferenceExpired):
574
  return True
575
+ probes = [
576
  getattr(exc, "ID", None),
577
  type(exc).__name__,
578
  getattr(exc, "value", None),
579
  str(exc),
580
+ ]
581
+ if getattr(exc, "__cause__", None):
582
+ probes.append(str(exc.__cause__))
583
+ if getattr(exc, "__context__", None):
584
+ probes.append(str(exc.__context__))
585
  for probe in probes:
586
  if not probe:
587
  continue
 
2692
  try:
2693
  with open(strm_path, "r", encoding="utf-8") as f:
2694
  existing_content = f.read().strip()
2695
+ if existing_content == stream_url:
 
 
 
 
 
 
 
 
2696
  entry["target_dir"] = target_dir
2697
  entry["show_root"] = show_root
2698
  entry["strm_base"] = strm_base_name
 
3164
 
3165
 
3166
  def _stream_url(chat_id, message_id):
3167
+ return f"http://127.0.0.1:{PORT}/stream/{chat_id}/{message_id}/video.mp4"
 
 
 
 
3168
 
3169
 
3170
  @routes.post("/review/{chat_id}/{message_id}/approve")
 
4161
  return web.Response(status=400, text="message_id is required.")
4162
 
4163
  chat_id, message_id = source
4164
+ is_loopback = (
4165
+ request.remote in ("127.0.0.1", "::1", "localhost")
4166
+ or request.host.startswith("127.0.0.1:")
4167
+ or request.host.startswith("localhost:")
4168
+ or request.host.split(":")[0] in ("127.0.0.1", "localhost", "::1")
4169
+ )
4170
+ if STREAM_SIGNING_KEY and not is_loopback:
4171
  try:
4172
  expires = int(request.query.get("expires", "0"))
4173
  signature = request.query.get("sig", "")
 
4577
  os.remove(path)
4578
  logger.info(f'Removed obsolete Go STRM: {path}')
4579
  removed += 1
4580
+ elif '?' in content:
4581
+ cleaned = content.split('?')[0].strip()
4582
+ if cleaned and cleaned != content:
4583
+ with open(path, 'w', encoding='utf-8') as f_out:
4584
+ f_out.write(cleaned)
4585
+ logger.info(f'[RESTORE] Sanitized query token in .strm: {path}')
4586
  except Exception:
4587
  pass
4588
  if removed: