Spaces:
Running
Running
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 +4 -7
- apex/combined-entrypoint.sh +1 -1
- apex/tg_streamer.py +46 -30
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
|
| 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 |
-
|
| 156 |
-
os.makedirs(
|
|
|
|
|
|
|
|
|
|
| 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
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|