no-name-here commited on
Commit
6fc3a02
·
verified ·
1 Parent(s): bd93121

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1321 -125
app.py CHANGED
@@ -1,149 +1,1345 @@
1
  import logging
2
- from fastapi import FastAPI, Request
 
 
 
 
 
 
 
 
 
 
3
 
4
- app = FastAPI()
5
- logging.basicConfig(level=logging.INFO)
 
 
 
6
 
7
- user_storage = {}
 
8
 
9
- def send_message(chat_id, text):
10
- return {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  "method": "sendMessage",
12
  "chat_id": chat_id,
13
  "text": text,
14
- "parse_mode": "Markdown"
15
  }
 
 
 
16
 
17
- def extract_file(message):
18
- if "video" in message:
19
- return "video", message["video"]["file_id"]
20
- if "document" in message:
21
- return "document", message["document"]["file_id"]
22
- if "animation" in message:
23
- return "animation", message["animation"]["file_id"]
24
- if "video_note" in message:
25
- return "video_note", message["video_note"]["file_id"]
26
- return None, None
 
 
27
 
 
 
 
 
 
 
 
 
 
 
28
 
29
- @app.post("/webhook")
30
- async def webhook(request: Request):
31
- update = await request.json()
32
- logging.info(update)
33
-
34
- if "message" not in update:
35
- return {"ok": True}
36
-
37
- message = update["message"]
38
- chat_id = message["chat"]["id"]
39
- user_id = message["from"]["id"]
40
- message_id = message["message_id"]
41
- text = message.get("text", "")
42
-
43
- # Store file
44
- method, file_id = extract_file(message)
45
- if file_id:
46
- user_storage[user_id] = {
47
- "file_id": file_id,
48
- "message_id": message_id,
49
- "method": method
50
- }
51
- return send_message(chat_id, f"Stored as `{method}`.\nUse /forcevideo")
52
-
53
- # Force everything
54
- if text.startswith("/forcevideo"):
55
- if user_id not in user_storage:
56
- return send_message(chat_id, "Send file first.")
57
-
58
- file_id = user_storage[user_id]["file_id"]
59
- original_message_id = user_storage[user_id]["message_id"]
60
-
61
- # ATTEMPT 1 — sendVideo aggressive
62
- return {
63
- "method": "sendVideo",
64
- "chat_id": chat_id,
65
- "video": file_id,
66
- "caption": "🔥 Attempt 1: sendVideo",
67
- "supports_streaming": True,
68
- "width": 720,
69
- "height": 1280,
70
- "duration": 60
71
- }
72
 
73
- if text.startswith("/force2"):
74
- file_id = user_storage[user_id]["file_id"]
75
-
76
- # ATTEMPT 2 — sendMediaGroup trick
77
- return {
78
- "method": "sendMediaGroup",
79
- "chat_id": chat_id,
80
- "media": [
81
- {
82
- "type": "video",
83
- "media": file_id,
84
- "caption": "🔥 Attempt 2: MediaGroup"
85
- }
86
- ]
87
- }
88
 
89
- if text.startswith("/force3"):
90
- file_id = user_storage[user_id]["file_id"]
 
 
 
 
 
 
91
 
92
- # ATTEMPT 3 — sendAnimation
93
- return {
94
- "method": "sendAnimation",
95
- "chat_id": chat_id,
96
- "animation": file_id,
97
- "caption": "🔥 Attempt 3: Animation trick"
98
- }
 
 
 
 
99
 
100
- if text.startswith("/force4"):
101
- file_id = user_storage[user_id]["file_id"]
 
 
 
 
102
 
103
- # ATTEMPT 4 — sendVideoNote
104
- return {
105
- "method": "sendVideoNote",
106
- "chat_id": chat_id,
107
- "video_note": file_id,
108
- "length": 360
109
- }
 
 
 
 
 
 
 
 
 
 
 
110
 
111
- if text.startswith("/force5"):
112
- data = user_storage[user_id]
113
-
114
- # ATTEMPT 5 — editMessageMedia (recast)
115
- return {
116
- "method": "editMessageMedia",
117
- "chat_id": chat_id,
118
- "message_id": data["message_id"],
119
- "media": {
120
- "type": "video",
121
- "media": data["file_id"],
122
- "caption": "🔥 Attempt 5: Edit media"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  }
124
- }
125
 
126
- if text.startswith("/force6"):
127
- data = user_storage[user_id]
128
 
129
- # ATTEMPT 6 — copyMessage
130
- return {
131
- "method": "copyMessage",
132
- "chat_id": chat_id,
133
- "from_chat_id": chat_id,
134
- "message_id": data["message_id"],
135
- "caption": "🔥 Attempt 6: Copy"
136
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
- if text.startswith("/force7"):
139
- data = user_storage[user_id]
140
 
141
- # ATTEMPT 7 — forwardMessage
142
- return {
143
- "method": "forwardMessage",
144
- "chat_id": chat_id,
145
- "from_chat_id": chat_id,
146
- "message_id": data["message_id"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
- return {"ok": True}
 
 
 
 
 
 
1
  import logging
2
+ import threading
3
+ import time
4
+ import datetime
5
+ import traceback
6
+ import fractions
7
+ import json
8
+ import os
9
+ import re
10
+ from urllib.parse import urlparse
11
+ from pathlib import Path # Still useful for parsing, just not disk ops
12
+ import io # For handling bytes as files
13
 
14
+ from fastapi import FastAPI, Request, HTTPException, UploadFile, File, Form
15
+ import av
16
+ from PIL import Image, ImageEnhance, UnidentifiedImageError # Pillow is still needed for image manipulation
17
+ from apscheduler.schedulers.background import BackgroundScheduler
18
+ # SQLAlchemyJobStore is removed as we can't write to disk. APScheduler will use MemoryJobStore.
19
 
20
+ # --- Configuration & Global Constants ---
21
+ APP_VERSION = "2.0.1-inmemory" # Version reflects in-memory change
22
 
23
+ # --- FastAPI & Scheduler Initialization ---
24
+ app = FastAPI(title="Advanced Stream Bot (In-Memory Edition)", version=APP_VERSION)
25
+
26
+ # APScheduler for scheduled streams - will use default MemoryJobStore
27
+ scheduler = BackgroundScheduler(timezone="UTC")
28
+
29
+ # --- Global Data Stores (Managed per user where applicable) ---
30
+ # user_sessions: chat_id -> session_data_dict
31
+ # All data is now in-memory.
32
+ user_sessions = {}
33
+ # session_locks: chat_id -> threading.Lock() for thread-safe session updates
34
+ session_locks = {}
35
+
36
+ # Global list for general bot logs (rolling)
37
+ live_log_lines_global = []
38
+ MAX_GLOBAL_LOG_LINES = 100
39
+
40
+ # --- Logging Setup ---
41
+ logging.basicConfig(
42
+ level=logging.INFO,
43
+ format="%(asctime)s [%(levelname)s] %(threadName)s %(module)s:%(lineno)d %(message)s",
44
+ datefmt="%Y-%m-%d %H:%M:%S",
45
+ )
46
+ logger = logging.getLogger("stream_bot_inmemory")
47
+
48
+ def append_global_live_log(line: str):
49
+ global live_log_lines_global
50
+ live_log_lines_global.append(line)
51
+ if len(live_log_lines_global) > MAX_GLOBAL_LOG_LINES:
52
+ live_log_lines_global.pop(0)
53
+
54
+ class GlobalListHandler(logging.Handler):
55
+ def emit(self, record):
56
+ log_entry = self.format(record)
57
+ append_global_live_log(log_entry)
58
+
59
+ global_list_handler = GlobalListHandler()
60
+ global_list_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
61
+ logger.addHandler(global_list_handler)
62
+
63
+
64
+ # --- Per-User Session Management (In-Memory) ---
65
+
66
+ DEFAULT_USER_SETTINGS = {
67
+ "input_url_playlist": [],
68
+ "current_playlist_index": 0,
69
+ "output_url": "rtmp://a.rtmp.youtube.com/live2/",
70
+ "quality_settings": "medium",
71
+ "video_codec": "libx264",
72
+ "audio_codec": "aac",
73
+ "resolution": "source",
74
+ "fps": 30,
75
+ "gop_size": 60,
76
+ "video_bitrate": "1500k",
77
+ "audio_bitrate": "128k",
78
+ "loop_count": 0,
79
+ "stop_on_error_in_playlist": True,
80
+ "ffmpeg_preset": "medium",
81
+ "logo_enabled": False,
82
+ "logo_data_bytes": None, # Store logo as bytes
83
+ "logo_mime_type": None, # e.g., 'image/png'
84
+ "logo_original_filename": None, # For display
85
+ "logo_position": "top_right",
86
+ "logo_scale": 0.1,
87
+ "logo_opacity": 0.8,
88
+ "advanced_mode": False,
89
+ "current_step": None,
90
+ "conversation_fields_list": [],
91
+ }
92
+
93
+ DEFAULT_SESSION_RUNTIME_STATE = {
94
+ "streaming_state": "idle",
95
+ "stream_start_time": None,
96
+ "frames_encoded": 0,
97
+ "bytes_sent": 0,
98
+ "stream_thread_ref": None,
99
+ "pyav_objects": {"input_container": None, "output_container": None, "video_out_stream": None, "audio_out_stream": None, "logo_image_pil": None},
100
+ "live_log_lines_user": [],
101
+ "error_notification_user": "",
102
+ "stop_gracefully_flag": False,
103
+ "current_loop_iteration": 0,
104
+ }
105
+
106
+ def get_user_session(chat_id: int) -> dict:
107
+ if chat_id not in user_sessions:
108
+ session_locks[chat_id] = threading.Lock()
109
+ with session_locks[chat_id]:
110
+ # Initialize new session with defaults as there's no file to load from
111
+ user_sessions[chat_id] = {}
112
+ # Deep copy default settings to avoid shared mutable objects
113
+ for key, value in DEFAULT_USER_SETTINGS.items():
114
+ user_sessions[chat_id][key] = list(value) if isinstance(value, list) else value
115
+
116
+ for key, value in DEFAULT_SESSION_RUNTIME_STATE.items():
117
+ user_sessions[chat_id][key] = list(value) if isinstance(value, list) else value
118
+
119
+ logger.info(f"[Chat {chat_id}] New IN-MEMORY session created. Settings are NOT persistent.")
120
+ return user_sessions[chat_id]
121
+
122
+ # save_user_settings_to_file, load_user_settings_from_file, load_all_sessions_on_startup are REMOVED.
123
+ # Settings are only saved in memory for the current runtime.
124
+
125
+ # --- User-specific Live Logging ---
126
+ def append_user_live_log(chat_id: int, line: str):
127
+ session = get_user_session(chat_id) # Ensures session exists
128
+ # No need for lock here if list append is atomic and reading is infrequent / tolerant
129
+ # but for consistency with other session modifications:
130
+ lock = session_locks.get(chat_id)
131
+ if lock: # Should always exist after get_user_session
132
+ with lock:
133
+ session['live_log_lines_user'].append(f"{datetime.datetime.now().strftime('%H:%M:%S')} {line}")
134
+ if len(session['live_log_lines_user']) > 50:
135
+ session['live_log_lines_user'].pop(0)
136
+ else: # Fallback if lock somehow not initialized
137
+ logger.warning(f"[Chat {chat_id}] Lock not found for appending user log. This is unexpected.")
138
+ session['live_log_lines_user'].append(f"{datetime.datetime.now().strftime('%H:%M:%S')} {line}")
139
+ if len(session['live_log_lines_user']) > 50:
140
+ session['live_log_lines_user'].pop(0)
141
+
142
+
143
+ # --- Input Validation ---
144
+ SUPPORTED_VIDEO_CODECS = ["libx264", "h264_nvenc", "h264_qsv", "libx265", "hevc_nvenc", "hevc_qsv", "copy"]
145
+ SUPPORTED_AUDIO_CODECS = ["aac", "opus", "libmp3lame", "copy"]
146
+ LOGO_POSITIONS = ["top_left", "top_right", "bottom_left", "bottom_right", "center"]
147
+ QUALITY_SETTINGS_MAP = {
148
+ "low": {"video_bitrate": "800k", "audio_bitrate": "96k", "ffmpeg_preset": "superfast", "resolution": "854x480"},
149
+ "medium": {"video_bitrate": "1500k", "audio_bitrate": "128k", "ffmpeg_preset": "medium", "resolution": "1280x720"},
150
+ "high": {"video_bitrate": "3000k", "audio_bitrate": "192k", "ffmpeg_preset": "fast", "resolution": "1920x1080"},
151
+ "source": {}
152
+ }
153
+
154
+ def validate_url(url: str) -> bool:
155
+ try:
156
+ result = urlparse(url)
157
+ return all([result.scheme, result.netloc]) and result.scheme in ['http', 'https', 'rtmp', 'rtsp', 'udp', 'srt', 'file']
158
+ except:
159
+ return False
160
+
161
+ def validate_resolution(res: str) -> bool:
162
+ if res.lower() == "source":
163
+ return True
164
+ return bool(re.match(r"^\d{3,4}x\d{3,4}$", res))
165
+
166
+ def parse_bitrate(bitrate_str: str) -> int:
167
+ bitrate_str = str(bitrate_str).lower() # Ensure it's a string
168
+ if bitrate_str.endswith('k'):
169
+ return int(float(bitrate_str[:-1]) * 1000)
170
+ elif bitrate_str.endswith('m'):
171
+ return int(float(bitrate_str[:-1]) * 1000000)
172
+ try:
173
+ return int(bitrate_str)
174
+ except ValueError:
175
+ return 1500000
176
+
177
+ def format_settings_for_display(session: dict) -> str:
178
+ logo_status = "Disabled"
179
+ if session.get('logo_enabled') and session.get('logo_data_bytes'):
180
+ logo_status = f"Enabled ({session.get('logo_original_filename', 'Unknown name')})"
181
+
182
+ display_data = {
183
+ "Output URL": session.get('output_url'),
184
+ "Quality": session.get('quality_settings'),
185
+ "Video Codec": session.get('video_codec'),
186
+ "Audio Codec": session.get('audio_codec'),
187
+ "Resolution": session.get('resolution'),
188
+ "FPS": session.get('fps'),
189
+ "Video Bitrate": session.get('video_bitrate'),
190
+ "Audio Bitrate": session.get('audio_bitrate'),
191
+ "FFmpeg Preset": session.get('ffmpeg_preset'),
192
+ "Loop Count": "Infinite" if session.get('loop_count') == -1 else session.get('loop_count'),
193
+ "Playlist Length": len(session.get('input_url_playlist', [])),
194
+ "Logo": logo_status,
195
+ }
196
+ if session.get('logo_enabled') and session.get('logo_data_bytes'):
197
+ display_data["Logo Position"] = session.get('logo_position')
198
+ display_data["Logo Scale"] = session.get('logo_scale')
199
+ display_data["Logo Opacity"] = session.get('logo_opacity')
200
+
201
+ return "\n".join([f"• *{k}*: `{v}`" for k, v in display_data.items() if v is not None])
202
+
203
+
204
+ # --- UI Helpers (send_message, edit_message_text, answer_callback_query, get_main_keyboard, get_uptime, compose_status_message) ---
205
+ # These functions remain largely the same as they don't interact with disk.
206
+ # Minor change in compose_status_message for logo display from bytes.
207
+ def send_message(chat_id: int, text: str, parse_mode="Markdown", reply_markup=None):
208
+ logger.info(f"[Chat {chat_id}] Sending message: {text[:100]}...")
209
+ response = {
210
  "method": "sendMessage",
211
  "chat_id": chat_id,
212
  "text": text,
213
+ "parse_mode": parse_mode
214
  }
215
+ if reply_markup:
216
+ response["reply_markup"] = reply_markup
217
+ return response
218
 
219
+ def edit_message_text(chat_id: int, message_id: int, text: str, parse_mode="Markdown", reply_markup=None):
220
+ logger.info(f"[Chat {chat_id}] Editing message {message_id}: {text[:100]}...")
221
+ response = {
222
+ "method": "editMessageText",
223
+ "chat_id": chat_id,
224
+ "message_id": message_id,
225
+ "text": text,
226
+ "parse_mode": parse_mode
227
+ }
228
+ if reply_markup:
229
+ response["reply_markup"] = reply_markup
230
+ return response
231
 
232
+ def answer_callback_query(callback_query_id: str, text: str = None, show_alert: bool = False):
233
+ response = {
234
+ "method": "answerCallbackQuery",
235
+ "callback_query_id": callback_query_id,
236
+ }
237
+ if text:
238
+ response["text"] = text
239
+ if show_alert:
240
+ response["show_alert"] = True
241
+ return response
242
 
243
+ def get_main_keyboard(session: dict):
244
+ streaming = session.get('streaming_state') in ["streaming", "paused", "starting"]
245
+ buttons = []
246
+ if not streaming:
247
+ buttons.append([{"text": "🚀 Start/Configure Stream", "callback_data": "cfg_start"}])
248
+ buttons.append([
249
+ {"text": "⚙️ Settings", "callback_data": "cfg_advanced"},
250
+ {"text": "📜 Playlist (" + str(len(session.get('input_url_playlist',[]))) + ")", "callback_data": "cfg_playlist"}
251
+ ])
252
+ buttons.append([
253
+ {"text": "🖼️ Logo", "callback_data": "cfg_logo"},
254
+ {"text": "🕒 Schedule", "callback_data": "cfg_schedule"}
255
+ ])
256
+ else:
257
+ if session.get('streaming_state') == "streaming":
258
+ buttons.append([{"text": "⏸ Pause", "callback_data": "stream_pause"}])
259
+ elif session.get('streaming_state') == "paused":
260
+ buttons.append([{"text": "▶️ Resume", "callback_data": "stream_resume"}])
261
+
262
+ buttons.append([
263
+ {"text": "⏹ Abort Stream", "callback_data": "stream_abort"},
264
+ {"text": "📊 Status Update", "callback_data": "stream_status"}
265
+ ])
266
+ if session.get('loop_count', 0) == -1:
267
+ buttons.append([{"text": "⏳ Stop Loop Gracefully", "callback_data": "stream_stop_loop_graceful"}])
268
+
269
+ buttons.append([{"text": "📜 Global Logs", "callback_data": "show_global_logs"}, {"text": "❔ Help", "callback_data": "show_help"}])
270
+ return {"inline_keyboard": buttons}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
 
273
+ def get_uptime(start_time_obj):
274
+ if start_time_obj:
275
+ # Ensure start_time_obj is offset-aware if comparing with offset-aware now()
276
+ if start_time_obj.tzinfo is None:
277
+ start_time_obj = start_time_obj.replace(tzinfo=datetime.timezone.utc) # Assume UTC if naive
278
+ uptime_delta = datetime.datetime.now(datetime.timezone.utc) - start_time_obj
279
+ return str(uptime_delta).split('.')[0]
280
+ return "0s"
281
 
282
+ def compose_status_message(chat_id: int, include_config: bool = False) -> str:
283
+ session = get_user_session(chat_id)
284
+ state = session['streaming_state']
285
+
286
+ status_lines = [
287
+ f"🤖 *Stream Bot Status for Chat ID {chat_id} (In-Memory Mode)*",
288
+ f"_Settings, logos, and schedules are NOT persistent across bot restarts._",
289
+ f"State: `{state}`",
290
+ ]
291
+ if session.get('error_notification_user'):
292
+ status_lines.append(f"⚠️ Error: `{session['error_notification_user']}`")
293
 
294
+ if state in ["streaming", "paused", "starting", "stopping"]:
295
+ current_input_url_display = "N/A"
296
+ playlist = session.get('input_url_playlist', [])
297
+ playlist_idx = session.get('current_playlist_index', 0)
298
+ if playlist and 0 <= playlist_idx < len(playlist):
299
+ current_input_url_display = playlist[playlist_idx]
300
 
301
+ status_lines.extend([
302
+ f"Uptime: `{get_uptime(session['stream_start_time'])}`",
303
+ f"Frames Encoded: `{session['frames_encoded']}`",
304
+ f"Bytes Sent: `{session['bytes_sent'] / (1024*1024):.2f} MB`",
305
+ f"Current Input: `{current_input_url_display}` (Item {playlist_idx+1}/{len(playlist)})",
306
+ f"Loop Iteration: {session.get('current_loop_iteration',0)+1}/{'Infinite' if session.get('loop_count',0) == -1 else session.get('loop_count',0) or 1}"
307
+ ])
308
+
309
+ if include_config or state not in ["streaming", "paused"]:
310
+ status_lines.append("\n📝 *Current Configuration:*")
311
+ status_lines.append(format_settings_for_display(session))
312
+
313
+ if state in ["streaming", "paused", "starting"]:
314
+ status_lines.append("\n📋 *User Live Logs (last 10):*")
315
+ user_logs_preview = "\n".join(session['live_log_lines_user'][-10:])
316
+ status_lines.append(f"<pre>{user_logs_preview if user_logs_preview else 'No user logs yet.'}</pre>")
317
+
318
+ return "\n".join(status_lines)
319
 
320
+
321
+ # --- Core Streaming Logic ---
322
+ # stream_engine_thread_target needs to load logo from session['logo_data_bytes']
323
+ def stream_engine_thread_target(chat_id: int):
324
+ session = get_user_session(chat_id)
325
+ lock = session_locks[chat_id]
326
+
327
+ active_input_container = None
328
+ active_output_container = None
329
+ active_video_out_stream = None
330
+ active_audio_out_stream = None
331
+ # logo_image_pil is now loaded from bytes each time, or stored in pyav_objects for the thread's duration
332
+ # logo_width, logo_height = 0, 0 # These will be calculated
333
+ # logo_pos_x, logo_pos_y = 0, 0 # These will be calculated
334
+
335
+
336
+ def _cleanup_pyav_resources():
337
+ # This function is called within the stream thread
338
+ # Access pyav_objects directly from session inside the lock
339
+ append_user_live_log(chat_id, "Cleaning up PyAV resources...")
340
+ with lock: # Ensure thread-safe access to session's pyav_objects
341
+ pyav_objs = session.get('pyav_objects', {})
342
+
343
+ if pyav_objs.get('output_container'):
344
+ try: pyav_objs['output_container'].close()
345
+ except Exception as e_c: append_user_live_log(chat_id, f"Error closing output_container: {e_c}")
346
+ pyav_objs['output_container'] = None
347
+ pyav_objs['video_out_stream'] = None
348
+ pyav_objs['audio_out_stream'] = None
349
+
350
+ if pyav_objs.get('input_container'):
351
+ try: pyav_objs['input_container'].close()
352
+ except Exception as e_c: append_user_live_log(chat_id, f"Error closing input_container: {e_c}")
353
+ pyav_objs['input_container'] = None
354
+
355
+ pyav_objs['logo_image_pil'] = None # Clear processed PIL image
356
+
357
+ # session['pyav_objects'] = pyav_objs # Already modified in place
358
+ append_user_live_log(chat_id, "PyAV resources cleanup attempt complete.")
359
+
360
+ try:
361
+ with lock:
362
+ session['streaming_state'] = "starting"
363
+ session['error_notification_user'] = ""
364
+ session['stream_start_time'] = datetime.datetime.now(datetime.timezone.utc)
365
+ session['frames_encoded'] = 0
366
+ session['bytes_sent'] = 0
367
+ session['current_loop_iteration'] = 0
368
+ session['current_playlist_index'] = 0
369
+ session['stop_gracefully_flag'] = False
370
+ # Reset pyav_objects for this run, ensures clean state
371
+ session['pyav_objects'] = {
372
+ "input_container": None, "output_container": None,
373
+ "video_out_stream": None, "audio_out_stream": None,
374
+ "logo_image_pil": None
375
  }
 
376
 
 
 
377
 
378
+ append_user_live_log(chat_id, f"Stream engine started. Output: {session['output_url']}")
379
+
380
+ try:
381
+ active_output_container = av.open(session['output_url'], mode='w', format='flv', timeout=10)
382
+ with lock: session['pyav_objects']['output_container'] = active_output_container
383
+ append_user_live_log(chat_id, f"Output RTMP connection established: {session['output_url']}")
384
+ except Exception as e:
385
+ raise Exception(f"Failed to open output RTMP stream: {e}")
386
+
387
+ # --- Logo Setup (if enabled and data exists) ---
388
+ # This is done once per stream start, assuming logo doesn't change mid-stream
389
+ # The processed PIL image is stored in session['pyav_objects']['logo_image_pil']
390
+ # For this thread's use only.
391
+ if session.get('logo_enabled') and session.get('logo_data_bytes'):
392
+ logo_bytes = session.get('logo_data_bytes')
393
+ try:
394
+ logo_image_pil_original = Image.open(io.BytesIO(logo_bytes)).convert("RGBA")
395
+
396
+ if session.get('logo_opacity', 1.0) < 1.0:
397
+ alpha = logo_image_pil_original.split()[-1]
398
+ alpha = ImageEnhance.Brightness(alpha).enhance(session['logo_opacity'])
399
+ logo_image_pil_original.putalpha(alpha)
400
+
401
+ with lock: # Store the processed PIL image for this stream thread
402
+ session['pyav_objects']['logo_image_pil'] = logo_image_pil_original
403
+ append_user_live_log(chat_id, f"Logo loaded from in-memory bytes: {session.get('logo_original_filename', 'N/A')}")
404
+ except UnidentifiedImageError:
405
+ append_user_live_log(chat_id, f"Error: Could not load logo image from bytes. Skipping logo.")
406
+ except Exception as e:
407
+ append_user_live_log(chat_id, f"Error processing logo from bytes: {e}. Skipping logo.")
408
+ else:
409
+ append_user_live_log(chat_id, "Logo not enabled or no logo data. Skipping logo.")
410
+
411
+
412
+ total_loops = session.get('loop_count', 0)
413
+ playlist = session.get('input_url_playlist', [])
414
+
415
+ if not playlist:
416
+ raise Exception("Playlist is empty. Nothing to stream.")
417
+
418
+ # --- Main Loop (Loops & Playlist) ---
419
+ while True:
420
+ with lock:
421
+ if session['stop_gracefully_flag'] or session['streaming_state'] == "stopping":
422
+ append_user_live_log(chat_id, "Graceful stop or abort signal received. Ending outer loop.")
423
+ break
424
+ current_loop_iter = session['current_loop_iteration']
425
+
426
+ # total_loops == -1: infinite; total_loops == 0: play once (no repeat); total_loops > 0: repeat N times
427
+ if total_loops != -1 and current_loop_iter >= max(total_loops, 1):
428
+ append_user_live_log(chat_id, f"Completed {max(total_loops, 1)} loop(s).")
429
+ break
430
+
431
+ append_user_live_log(chat_id, f"Starting loop iteration {current_loop_iter + 1}")
432
+ with lock: session['current_playlist_index'] = 0
433
+
434
+ while True:
435
+ with lock:
436
+ current_idx = session['current_playlist_index']
437
+ should_stop = session.get('stop_gracefully_flag') or session['streaming_state'] == "stopping"
438
+ playlist_exhausted = current_idx >= len(playlist)
439
+ if should_stop:
440
+ append_user_live_log(chat_id, "Graceful stop or abort signal received. Ending playlist loop.")
441
+ break
442
+ if playlist_exhausted:
443
+ break
444
+ current_input_url = playlist[current_idx]
445
+ append_user_live_log(chat_id, f"Processing playlist item {current_idx + 1}/{len(playlist)}: {current_input_url}")
446
+
447
+ # Local vars for this specific input processing cycle.
448
+ # These do not directly use session['pyav_objects'] for their primary reference
449
+ # but update it upon successful creation/assignment.
450
+ _current_input_container = None
451
+ # Output streams are more persistent across playlist items IF compatible.
452
+ # For simplicity, we define them once based on the first item or re-check.
453
+
454
+ try:
455
+ _current_input_container = av.open(current_input_url, timeout=10)
456
+ # Store in session for potential cleanup if error occurs before local var is cleared
457
+ with lock: session['pyav_objects']['input_container'] = _current_input_container
458
+ append_user_live_log(chat_id, f"Input opened: {current_input_url}")
459
+
460
+ in_v_streams = _current_input_container.streams.video
461
+ in_a_streams = _current_input_container.streams.audio
462
+ if not in_v_streams: raise Exception("No video stream found in input.")
463
+
464
+ in_v_s = in_v_streams[0]
465
+ target_width, target_height = in_v_s.width, in_v_s.height
466
+ if session['resolution'] != "source":
467
+ try:
468
+ target_width, target_height = map(int, session['resolution'].split('x'))
469
+ except ValueError:
470
+ append_user_live_log(chat_id, f"Invalid target resolution '{session['resolution']}'. Using source.")
471
+ target_width, target_height = in_v_s.width, in_v_s.height
472
+
473
+ # Retrieve output streams from session if they exist, or create them
474
+ with lock:
475
+ active_video_out_stream = session['pyav_objects'].get('video_out_stream')
476
+ active_audio_out_stream = session['pyav_objects'].get('audio_out_stream')
477
+ logo_pil_original_ref = session['pyav_objects'].get('logo_image_pil') # This is the pre-opacity PIL image
478
+
479
+ # --- (Re)configure video output stream ---
480
+ # Only configure if not already done or if significantly changed (here, simplified to "first time")
481
+ is_first_input_or_reconfig_needed = active_video_out_stream is None
482
+
483
+ # Local var for resized logo for current video dimensions
484
+ current_logo_pil_resized = None
485
+ logo_pos_x, logo_pos_y = 0,0 # Reset for each potential reconfig
486
+
487
+ if is_first_input_or_reconfig_needed:
488
+ active_video_out_stream = active_output_container.add_stream(session['video_codec'], rate=session['fps'])
489
+ active_video_out_stream.width = target_width
490
+ active_video_out_stream.height = target_height
491
+ active_video_out_stream.pix_fmt = 'yuv420p'
492
+ active_video_out_stream.codec_context.gop_size = session.get('gop_size', int(session['fps'] * 2))
493
+ active_video_out_stream.codec_context.bit_rate = parse_bitrate(session['video_bitrate'])
494
+ active_video_out_stream.codec_context.thread_type = "AUTO"
495
+ active_video_out_stream.codec_context.options = {'preset': session.get('ffmpeg_preset', 'medium')}
496
+ if session['video_codec'] == 'copy':
497
+ active_video_out_stream.codec_context.copy_parameters(in_v_s.codec_context)
498
+
499
+ with lock: session['pyav_objects']['video_out_stream'] = active_video_out_stream
500
+ append_user_live_log(chat_id, f"Video output stream configured: {target_width}x{target_height}@{session['fps']}fps, {session['video_bitrate']}")
501
+
502
+ # Logo scaling and positioning based on this video stream's output height
503
+ if logo_pil_original_ref: # The pre-opacity, original ratio PIL object
504
+ logo_aspect_ratio = logo_pil_original_ref.width / logo_pil_original_ref.height
505
+ logo_h = int(active_video_out_stream.height * session.get('logo_scale', 0.1))
506
+ logo_w = int(logo_h * logo_aspect_ratio)
507
+ logo_w = min(logo_w, active_video_out_stream.width) # cap width
508
+ logo_h = min(logo_h, active_video_out_stream.height) # cap height
509
+
510
+ if logo_w > 0 and logo_h > 0:
511
+ current_logo_pil_resized = logo_pil_original_ref.resize((logo_w, logo_h), Image.Resampling.LANCZOS)
512
+ append_user_live_log(chat_id, f"Logo resized to {logo_w}x{logo_h} for current video item.")
513
+
514
+ # Calculate position
515
+ margin = 10
516
+ if session['logo_position'] == "top_right":
517
+ logo_pos_x = active_video_out_stream.width - logo_w - margin
518
+ logo_pos_y = margin
519
+ elif session['logo_position'] == "top_left":
520
+ logo_pos_x = margin; logo_pos_y = margin
521
+ elif session['logo_position'] == "bottom_left":
522
+ logo_pos_x = margin; logo_pos_y = active_video_out_stream.height - logo_h - margin
523
+ elif session['logo_position'] == "bottom_right":
524
+ logo_pos_x = active_video_out_stream.width - logo_w - margin
525
+ logo_pos_y = active_video_out_stream.height - logo_h - margin
526
+ elif session['logo_position'] == "center":
527
+ logo_pos_x = (active_video_out_stream.width - logo_w) // 2
528
+ logo_pos_y = (active_video_out_stream.height - logo_h) // 2
529
+ else: # Default top_right
530
+ logo_pos_x = active_video_out_stream.width - logo_w - margin; logo_pos_y = margin
531
+ append_user_live_log(chat_id, f"Logo position set to ({logo_pos_x}, {logo_pos_y}) for current video item.")
532
+ else:
533
+ append_user_live_log(chat_id, "Logo resulted in 0 dimension after scaling for current video. Logo disabled for this item.")
534
+ current_logo_pil_resized = None # Disable for this item
535
+
536
+
537
+ # --- (Re)configure audio output stream ---
538
+ if is_first_input_or_reconfig_needed and in_a_streams:
539
+ in_a_s = in_a_streams[0]
540
+ active_audio_out_stream = active_output_container.add_stream(session['audio_codec'], rate=in_a_s.rate)
541
+ active_audio_out_stream.codec_context.bit_rate = parse_bitrate(session['audio_bitrate'])
542
+ if session['audio_codec'] == 'copy':
543
+ active_audio_out_stream.codec_context.copy_parameters(in_a_s.codec_context)
544
+ with lock: session['pyav_objects']['audio_out_stream'] = active_audio_out_stream
545
+ append_user_live_log(chat_id, f"Audio output stream configured: {in_a_s.rate}Hz, {session['audio_bitrate']}")
546
+ elif is_first_input_or_reconfig_needed and not in_a_streams:
547
+ append_user_live_log(chat_id, "No audio stream in input. Streaming video only.")
548
+ active_audio_out_stream = None
549
+ with lock: session['pyav_objects']['audio_out_stream'] = None
550
+
551
+
552
+ with lock:
553
+ if session['streaming_state'] != "paused":
554
+ session['streaming_state'] = "streaming"
555
+
556
+ # --- Packet Processing Loop (for current input URL) ---
557
+ for packet in _current_input_container.demux(video=0, audio=0 if in_a_streams else -1):
558
+ with lock:
559
+ if session['streaming_state'] == "stopping": break
560
+ while session['streaming_state'] == "paused":
561
+ lock.release(); time.sleep(0.2); lock.acquire()
562
+ if session['streaming_state'] == "stopping": break
563
+ if session['streaming_state'] == "stopping": break
564
+
565
+ if packet.dts is None: continue
566
+
567
+ if packet.stream.type == 'video' and packet.stream.index == in_v_s.index:
568
+ for frame in packet.decode():
569
+ if not frame.width or not frame.height: continue
570
+
571
+ if frame.width != active_video_out_stream.width or frame.height != active_video_out_stream.height:
572
+ frame = frame.reformat(active_video_out_stream.width, active_video_out_stream.height, format='yuv420p')
573
+
574
+ if current_logo_pil_resized and active_video_out_stream: # Use the resized logo for *this* video item
575
+ try:
576
+ pil_frame = frame.to_image()
577
+ # Ensure logo_w, logo_h from current_logo_pil_resized are used for bounds
578
+ actual_x = min(logo_pos_x, pil_frame.width - current_logo_pil_resized.width)
579
+ actual_y = min(logo_pos_y, pil_frame.height - current_logo_pil_resized.height)
580
+ actual_x = max(0, actual_x)
581
+ actual_y = max(0, actual_y)
582
+
583
+ pil_frame.paste(current_logo_pil_resized, (actual_x, actual_y), current_logo_pil_resized)
584
+ # frame = av.VideoFrame.from_image(pil_frame) # This creates a new frame
585
+ # Preserve original frame's PTS if possible, or let encoder handle it
586
+ new_pts = frame.pts # Keep original PTS
587
+ frame = av.VideoFrame.from_image(pil_frame)
588
+ frame.pts = new_pts
589
+
590
+ except Exception as e_logo:
591
+ append_user_live_log(chat_id, f"Error applying logo: {e_logo}.")
592
+
593
+ for out_packet in active_video_out_stream.encode(frame):
594
+ active_output_container.mux(out_packet)
595
+ with lock:
596
+ session['frames_encoded'] += 1
597
+ session['bytes_sent'] += out_packet.size
598
+
599
+ elif active_audio_out_stream and packet.stream.type == 'audio' and packet.stream.index == in_a_streams[0].index :
600
+ for frame in packet.decode():
601
+ if frame.pts is None: continue
602
+ for out_packet in active_audio_out_stream.encode(frame):
603
+ active_output_container.mux(out_packet)
604
+ with lock:
605
+ session['bytes_sent'] += out_packet.size
606
+ else:
607
+ append_user_live_log(chat_id, f"Finished processing input: {current_input_url}")
608
+
609
+ except Exception as e_input:
610
+ tb_str = traceback.format_exc()
611
+ append_user_live_log(chat_id, f"Error processing input {current_input_url}: {e_input}\n{tb_str}")
612
+ with lock: session['error_notification_user'] = f"Error with {current_input_url}: {e_input}"
613
+ if session.get('stop_on_error_in_playlist', True):
614
+ append_user_live_log(chat_id, "Stopping stream due to error in playlist and stop_on_error_in_playlist=True.")
615
+ with lock: session['streaming_state'] = "stopping"
616
+ break
617
+ else:
618
+ append_user_live_log(chat_id, "Skipping to next item in playlist (if any).")
619
+ finally:
620
+ if _current_input_container: # Use the local var for this input item
621
+ try: _current_input_container.close()
622
+ except Exception as e_c: append_user_live_log(chat_id, f"Minor error closing input container: {e_c}")
623
+ # Clear from session too, as this specific input_container is done
624
+ with lock:
625
+ if session['pyav_objects']['input_container'] == _current_input_container:
626
+ session['pyav_objects']['input_container'] = None
627
+
628
+ with lock:
629
+ if session['streaming_state'] == "stopping": break
630
+ session['current_playlist_index'] += 1
631
+
632
+ with lock:
633
+ if session['streaming_state'] == "stopping": break
634
+ session['current_loop_iteration'] += 1
635
+
636
+ append_user_live_log(chat_id, "All playlist items and loops processed or stream stopped.")
637
+
638
+ except Exception as e_stream:
639
+ tb_str = traceback.format_exc()
640
+ append_user_live_log(chat_id, f"Fatal stream engine error: {e_stream}\n{tb_str}")
641
+ with lock:
642
+ session['error_notification_user'] = f"Stream failed: {e_stream}"
643
+ session['streaming_state'] = "error"
644
+ finally:
645
+ append_user_live_log(chat_id, "Stream engine finalizing...")
646
+
647
+ # Retrieve output streams from session for flushing
648
+ # This assumes active_output_container is still the one from the start of the stream.
649
+ _final_output_container = None
650
+ _final_video_out_stream = None
651
+ _final_audio_out_stream = None
652
+ with lock:
653
+ _final_output_container = session['pyav_objects'].get('output_container')
654
+ _final_video_out_stream = session['pyav_objects'].get('video_out_stream')
655
+ _final_audio_out_stream = session['pyav_objects'].get('audio_out_stream')
656
+
657
+ if _final_output_container: # Check if output container was successfully opened
658
+ append_user_live_log(chat_id, "Flushing encoders...")
659
+ if _final_video_out_stream:
660
+ for out_packet in _final_video_out_stream.encode():
661
+ try: _final_output_container.mux(out_packet)
662
+ except Exception as fe: append_user_live_log(chat_id, f"Err flushing V: {fe}")
663
+ if _final_audio_out_stream:
664
+ for out_packet in _final_audio_out_stream.encode():
665
+ try: _final_output_container.mux(out_packet)
666
+ except Exception as fe: append_user_live_log(chat_id, f"Err flushing A: {fe}")
667
+ append_user_live_log(chat_id, "Encoders flushed.")
668
+
669
+ _cleanup_pyav_resources()
670
+
671
+ with lock:
672
+ final_state = "completed" if not session['error_notification_user'] and session['streaming_state'] != "error" else "error"
673
+ if session['streaming_state'] == "stopping":
674
+ final_state = "stopped"
675
+ session['streaming_state'] = final_state
676
+ session['stream_thread_ref'] = None
677
+
678
+ append_user_live_log(chat_id, f"Stream engine finished. Final state: {final_state}.")
679
+ logger.info(f"[Chat {chat_id}] Stream ended with state: {final_state}. Error: {session['error_notification_user']}")
680
 
 
 
681
 
682
+ # --- Telegram Command and Callback Handlers ---
683
+ # handle_telegram_update, CONVERSATION_STEPS*, start_settings_conversation, handle_conversation_step
684
+ # handle_playlist_command, handle_playlist_config_interaction remain similar,
685
+ # but they won't call save_user_settings_to_file. Changes are implicitly in-memory.
686
+
687
+ # handle_logo_upload needs to read file bytes and store them.
688
+ # handle_logo_config_interaction needs to reflect in-memory storage.
689
+ # schedule_stream_job, handle_schedule_command, handle_schedule_config_interaction are fine as APScheduler will use MemoryJobStore.
690
+ # Stream Control Command Handlers (start, pause, resume, abort) are mostly fine, no disk writes.
691
+ # get_help_text needs to be updated about non-persistence.
692
+
693
+ async def handle_telegram_update(update: dict):
694
+ if "message" in update:
695
+ message = update["message"]
696
+ chat_id = message["chat"]["id"]
697
+ user_name = message["from"].get("username", f"User_{chat_id}")
698
+ session = get_user_session(chat_id)
699
+ lock = session_locks[chat_id]
700
+
701
+ text = message.get("text", "")
702
+ command = text.split(' ')[0].lower() if text.startswith('/') else None
703
+
704
+ if message.get("photo") or message.get("document"):
705
+ if session.get("current_step") == "awaiting_logo_upload":
706
+ # Need to pass the actual UploadFile if using FastAPI's file handling for webhook
707
+ # For direct JSON, we'd get file_id and need a bot instance to download.
708
+ # Assuming direct JSON from Telegram with file_id:
709
+ return await handle_logo_data_from_telegram(chat_id, message)
710
+ else:
711
+ return send_message(chat_id, "Received a file, but I wasn't expecting one. Use /set_logo first if you want to upload a logo.")
712
+
713
+ if command:
714
+ logger.info(f"[Chat {chat_id} ({user_name})] Received command: {text}")
715
+ if command == "/start" or command == "/menu":
716
+ with lock: session['current_step'] = None
717
+ return send_message(chat_id, compose_status_message(chat_id, include_config=True), reply_markup=get_main_keyboard(session), parse_mode="HTML")
718
+ elif command == "/help":
719
+ return send_message(chat_id, get_help_text(), parse_mode="Markdown")
720
+ elif command == "/settings":
721
+ return start_settings_conversation(chat_id, advanced=True)
722
+ elif command == "/stream":
723
+ return await start_stream_command_handler(chat_id)
724
+ elif command == "/playlist":
725
+ return await handle_playlist_command(chat_id, text)
726
+ elif command == "/set_logo":
727
+ with lock: session["current_step"] = "awaiting_logo_upload"
728
+ return send_message(chat_id, "Please upload your logo image (PNG or JPG). Max 1MB recommended due to in-memory storage.")
729
+ elif command == "/schedule":
730
+ return await handle_schedule_command(chat_id, text)
731
+ elif command == "/status":
732
+ return send_message(chat_id, compose_status_message(chat_id, include_config=True), reply_markup=get_main_keyboard(session), parse_mode="HTML")
733
+ elif command == "/abort":
734
+ return await stream_abort_handler(chat_id)
735
+ else:
736
+ return await handle_conversation_step(chat_id, text)
737
+ else:
738
+ return await handle_conversation_step(chat_id, text)
739
+
740
+ elif "callback_query" in update:
741
+ cb_query = update["callback_query"]
742
+ chat_id = cb_query["message"]["chat"]["id"]
743
+ message_id = cb_query["message"]["message_id"]
744
+ data = cb_query["data"]
745
+ user_name = cb_query["from"].get("username", f"User_{chat_id}")
746
+ session = get_user_session(chat_id)
747
+ lock = session_locks[chat_id]
748
+
749
+ logger.info(f"[Chat {chat_id} ({user_name})] Received callback: {data}")
750
+
751
+ ack = answer_callback_query(cb_query["id"])
752
+
753
+ if data == "cfg_start":
754
+ if not session.get('input_url_playlist') or not session.get('output_url'):
755
+ # This is tricky as start_settings_conversation returns a message dict, not just text.
756
+ settings_response = start_settings_conversation(chat_id, advanced=session.get('advanced_mode', False))
757
+ response_text = "Playlist or Output URL is not set. Let's configure.\n" + settings_response['text']
758
+ # If settings_response has its own reply_markup, it might conflict.
759
+ # For simplicity, let's assume start_settings_conversation is primarily text.
760
+ return [ack, send_message(chat_id, response_text, reply_markup=settings_response.get('reply_markup') or get_main_keyboard(session))]
761
+
762
+ else:
763
+ return [ack, await start_stream_command_handler(chat_id, message_id_to_edit=message_id)]
764
+ elif data == "cfg_advanced":
765
+ settings_response = start_settings_conversation(chat_id, advanced=True)
766
+ return [ack, edit_message_text(chat_id, message_id, settings_response['text'] + "\n\n" + compose_status_message(chat_id, include_config=True), reply_markup=settings_response.get('reply_markup') or get_main_keyboard(session), parse_mode="HTML")]
767
+ elif data == "cfg_playlist":
768
+ return [ack, await handle_playlist_config_interaction(chat_id, message_id)]
769
+ elif data == "cfg_logo":
770
+ return [ack, await handle_logo_config_interaction(chat_id, message_id)]
771
+ elif data == "cfg_schedule":
772
+ return [ack, await handle_schedule_config_interaction(chat_id, message_id)]
773
+
774
+ elif data == "stream_pause":
775
+ return [ack, await stream_pause_handler(chat_id, message_id)]
776
+ elif data == "stream_resume":
777
+ return [ack, await stream_resume_handler(chat_id, message_id)]
778
+ elif data == "stream_abort":
779
+ return [ack, await stream_abort_handler(chat_id, message_id_to_edit=message_id)]
780
+ elif data == "stream_status":
781
+ return [ack, edit_message_text(chat_id, message_id, compose_status_message(chat_id, include_config=False), reply_markup=get_main_keyboard(session), parse_mode="HTML")]
782
+ elif data == "stream_stop_loop_graceful":
783
+ with lock: session['stop_gracefully_flag'] = True
784
+ append_user_live_log(chat_id, "Graceful loop stop requested.")
785
+ return [ack, edit_message_text(chat_id, message_id, "Graceful loop stop initiated. Current iteration will complete.\n" + compose_status_message(chat_id), reply_markup=get_main_keyboard(session), parse_mode="HTML")]
786
+
787
+ elif data == "show_global_logs":
788
+ global_logs_text = "📜 *Global Bot Logs (last 20):*\n<pre>" + "\n".join(live_log_lines_global[-20:]) + "</pre>"
789
+ return [ack, edit_message_text(chat_id, message_id, global_logs_text + "\n\n" + compose_status_message(chat_id), reply_markup=get_main_keyboard(session), parse_mode="HTML")]
790
+ elif data == "show_help":
791
+ return [ack, edit_message_text(chat_id, message_id, get_help_text() + "\n\n" + compose_status_message(chat_id), reply_markup=get_main_keyboard(session))]
792
+
793
+ elif data.startswith("set_quality_"):
794
+ quality = data.split("_")[-1]
795
+ with lock:
796
+ session["quality_settings"] = quality
797
+ if quality in QUALITY_SETTINGS_MAP and quality != "custom":
798
+ for key, val in QUALITY_SETTINGS_MAP[quality].items():
799
+ session[key] = val
800
+ session["current_step"] = None
801
+ # save_user_settings_to_file(chat_id) # REMOVED
802
+ return [ack, edit_message_text(chat_id, message_id, f"Quality set to `{quality}`.\n" + compose_status_message(chat_id, True), reply_markup=get_main_keyboard(session), parse_mode="HTML")]
803
+
804
+ return [ack, send_message(chat_id, "Unknown action.")]
805
+
806
+ return {"status": "ok", "message": "Update type not handled"}
807
+
808
+
809
+ CONVERSATION_STEPS_SIMPLE = [
810
+ ("input_url_playlist", "Enter the first Input URL for your playlist (e.g., http://example.com/stream.m3u8). You can add more later.", validate_url),
811
+ ("output_url", "Enter your RTMP Output URL (e.g., rtmp://a.rtmp.youtube.com/live2/YOUR_STREAM_KEY).", validate_url),
812
+ ]
813
+ CONVERSATION_STEPS_ADVANCED = [
814
+ ("input_url_playlist", "Enter the first Input URL for your playlist. You can add more later.", validate_url),
815
+ ("output_url", "Enter your RTMP Output URL.", validate_url),
816
+ ("quality_settings_prompt", "Choose Quality Preset: (low, medium, high, custom). Custom allows setting individual params next.", None),
817
+ ("video_codec", f"Video Codec (e.g., libx264). Supported: {', '.join(SUPPORTED_VIDEO_CODECS)}.", lambda x: x in SUPPORTED_VIDEO_CODECS),
818
+ ("audio_codec", f"Audio Codec (e.g., aac). Supported: {', '.join(SUPPORTED_AUDIO_CODECS)}.", lambda x: x in SUPPORTED_AUDIO_CODECS),
819
+ ("resolution", "Resolution (e.g., 1280x720 or 'source').", validate_resolution),
820
+ ("fps", "FPS (e.g., 30).", lambda x: str(x).isdigit() and 1 <= int(x) <= 120),
821
+ ("video_bitrate", "Video Bitrate (e.g., 1500k, 3M).", lambda x: True),
822
+ ("audio_bitrate", "Audio Bitrate (e.g., 128k).", lambda x: True),
823
+ ("ffmpeg_preset", "FFmpeg Preset (for libx264: ultrafast, medium, slow etc.).", lambda x: True),
824
+ ("loop_count", "Loop Count (0 for no loop, -1 for infinite, N for N times).", lambda x: str(x).lstrip('-').isdigit()),
825
+ ]
826
+
827
+ def start_settings_conversation(chat_id: int, advanced: bool):
828
+ session = get_user_session(chat_id)
829
+ lock = session_locks[chat_id]
830
+ with lock:
831
+ session['advanced_mode'] = advanced
832
+ session['conversation_fields_list'] = CONVERSATION_STEPS_ADVANCED if advanced else CONVERSATION_STEPS_SIMPLE
833
+ session['current_step_index'] = 0
834
+ session['current_step'] = session['conversation_fields_list'][0][0]
835
+
836
+ first_field_key, first_prompt, _ = session['conversation_fields_list'][0]
837
+
838
+ # Prepare initial message (send_message or part of edit_message_text)
839
+ initial_message_content = {}
840
+
841
+ if first_field_key == "quality_settings_prompt":
842
+ initial_message_content = {
843
+ "text": first_prompt,
844
+ "reply_markup": {
845
+ "inline_keyboard": [
846
+ [{"text": q.title(), "callback_data": f"set_quality_{q}"} for q in ["low", "medium", "high", "custom"]]
847
+ ]
848
+ }
849
  }
850
+ else:
851
+ current_value_msg = f"(Current: `{session.get(first_field_key, 'Not set')}`)" if session.get(first_field_key) else ""
852
+ initial_message_content = {"text": f"{first_prompt}\n{current_value_msg}\n\nType /cancel to abort setup."}
853
+
854
+ # This function is now called from callbacks too, so it needs to return a dict that can be used by send_message or edit_message_text
855
+ return initial_message_content
856
+
857
+
858
+ async def handle_conversation_step(chat_id: int, user_text: str):
859
+ session = get_user_session(chat_id)
860
+ lock = session_locks[chat_id]
861
+
862
+ current_step_key_local = None
863
+ validator_local = None
864
+ current_step_index_local = -1
865
+
866
+ with lock:
867
+ if not session.get('current_step') or not session.get('conversation_fields_list'):
868
+ return send_message(chat_id, "No setup in progress. Use /start or a menu button.", reply_markup=get_main_keyboard(session))
869
+
870
+ if user_text.lower() == "/cancel":
871
+ session['current_step'] = None
872
+ session['current_step_index'] = 0 # Reset index
873
+ cancelled = True
874
+ else:
875
+ cancelled = False
876
+
877
+ # Ensure current_step_index is valid
878
+ if not cancelled and not (0 <= session.get('current_step_index', -1) < len(session['conversation_fields_list'])):
879
+ session['current_step'] = None # Invalid state, reset
880
+ session['current_step_index'] = 0
881
+ logger.warning(f"[Chat {chat_id}] Invalid current_step_index, resetting conversation.")
882
+ invalid_index = True
883
+ else:
884
+ invalid_index = False
885
+
886
+ if not cancelled and not invalid_index:
887
+ current_step_index_local = session['current_step_index']
888
+ current_step_key_local, _, validator_local = session['conversation_fields_list'][current_step_index_local]
889
+
890
+ # Handle early exits outside the lock
891
+ if cancelled:
892
+ return send_message(chat_id, "Setup cancelled.", reply_markup=get_main_keyboard(session))
893
+ if invalid_index:
894
+ return send_message(chat_id, "Conversation state error, setup reset. Please start again.", reply_markup=get_main_keyboard(session))
895
+ user_input = user_text.strip()
896
+
897
+ if validator_local and not validator_local(user_input):
898
+ # Re-fetch prompt as it might have been clobbered if lock wasn't held long enough (should be fine here)
899
+ _, prompt, _ = session['conversation_fields_list'][current_step_index_local] # Use local index; tuple is (key, prompt, validator)
900
+ return send_message(chat_id, f"Invalid input for {current_step_key_local.replace('_',' ').title()}. Please try again.\n{prompt}")
901
+
902
+ with lock:
903
+ # Re-verify current_step_key just in case, though primary logic relies on index
904
+ if current_step_key_local != session['conversation_fields_list'][session['current_step_index']][0]:
905
+ logger.error(f"[Chat {chat_id}] Conversation step mismatch! Aborting step.")
906
+ # Build response outside lock below
907
+ step_mismatch = True
908
+ else:
909
+ step_mismatch = False
910
+
911
+ if not step_mismatch:
912
+ if current_step_key_local == "input_url_playlist":
913
+ session["input_url_playlist"] = [user_input] if user_input else []
914
+ elif current_step_key_local == "quality_settings_prompt":
915
+ quality_input = user_input.lower()
916
+ if quality_input in QUALITY_SETTINGS_MAP.keys() or quality_input == "custom":
917
+ session["quality_settings"] = quality_input
918
+ if quality_input in QUALITY_SETTINGS_MAP and quality_input != "custom":
919
+ for key, val in QUALITY_SETTINGS_MAP[quality_input].items(): session[key] = val
920
+ append_user_live_log(chat_id, f"Quality set to {quality_input} via text.")
921
+ else:
922
+ logger.warning(f"[Chat {chat_id}] Invalid quality input '{quality_input}' in conversation step. Not applied.")
923
+ elif current_step_key_local == "loop_count" or current_step_key_local == "fps":
924
+ session[current_step_key_local] = int(user_input)
925
+ else:
926
+ session[current_step_key_local] = user_input
927
+
928
+ append_user_live_log(chat_id, f"Set {current_step_key_local} = {user_input}")
929
+
930
+ session['current_step_index'] += 1
931
+ if session['current_step_index'] < len(session['conversation_fields_list']):
932
+ next_step_key, next_prompt, _ = session['conversation_fields_list'][session['current_step_index']]
933
+ session['current_step'] = next_step_key
934
+ next_step_is_quality = (next_step_key == "quality_settings_prompt")
935
+ current_value_msg = f"(Current: `{session.get(next_step_key, 'Not set')}`)" if session.get(next_step_key) else ""
936
+ else:
937
+ next_step_key = None
938
+ next_prompt = None
939
+ next_step_is_quality = False
940
+ current_value_msg = ""
941
+ session['current_step'] = None
942
+ session['current_step_index'] = 0
943
+ logger.info(f"[Chat {chat_id}] In-memory settings configured.")
944
+
945
+ # All returns happen outside the lock to avoid holding it during send_message calls
946
+ if step_mismatch:
947
+ return send_message(chat_id, "A state error occurred. Please try /cancel and restart setup.", reply_markup=get_main_keyboard(session))
948
+
949
+ if next_step_key is not None:
950
+ if next_step_is_quality:
951
+ return send_message(chat_id, next_prompt, reply_markup={
952
+ "inline_keyboard": [
953
+ [{"text": q.title(), "callback_data": f"set_quality_{q}"} for q in ["low", "medium", "high", "custom"]]
954
+ ]})
955
+ return send_message(chat_id, f"{next_prompt}\n{current_value_msg}\n\nType /cancel to abort setup.")
956
+ else:
957
+ # save_user_settings_to_file(chat_id) # REMOVED
958
+ return send_message(chat_id, "All settings configured (in memory for this session)!\n" + compose_status_message(chat_id, True), reply_markup=get_main_keyboard(session), parse_mode="HTML")
959
+
960
+
961
+ async def handle_playlist_command(chat_id: int, text: str):
962
+ session = get_user_session(chat_id)
963
+ lock = session_locks[chat_id]
964
+ parts = text.split(maxsplit=2)
965
+ action = parts[1].lower() if len(parts) > 1 else "show"
966
+ msg = ""
967
+
968
+ with lock:
969
+ playlist = session.get('input_url_playlist', [])
970
+ if action == "add" and len(parts) > 2:
971
+ url_to_add = parts[2]
972
+ if validate_url(url_to_add):
973
+ playlist.append(url_to_add)
974
+ # session['input_url_playlist'] = playlist # Modified in place
975
+ msg = f"Added to playlist. Total items: {len(playlist)}."
976
+ else:
977
+ msg = "Invalid URL format."
978
+ elif action == "remove" and len(parts) > 2:
979
+ idx_str = parts[2]
980
+ removed_item = None
981
+ if idx_str.lower() == "last" and playlist: removed_item = playlist.pop()
982
+ elif idx_str.isdigit():
983
+ idx_to_remove = int(idx_str) -1
984
+ if 0 <= idx_to_remove < len(playlist): removed_item = playlist.pop(idx_to_remove)
985
+ if removed_item: msg = f"Removed '{removed_item[:50]}...' from playlist. Total items: {len(playlist)}."
986
+ else: msg = "Invalid index or playlist empty."
987
+ elif action == "clear":
988
+ session['input_url_playlist'] = []
989
+ msg = "Playlist cleared."
990
+ elif action == "show":
991
+ if playlist:
992
+ plist_str = "\n".join([f"{i+1}. `{url}`" for i, url in enumerate(playlist)])
993
+ msg = f"*Current Playlist ({len(playlist)} items):*\n{plist_str}"
994
+ else: msg = "Playlist is empty."
995
+ else: msg = "Usage: /playlist [add <url>|remove <index|last>|clear|show]"
996
+
997
+ # save_user_settings_to_file(chat_id) # REMOVED (for playlist changes)
998
+ return send_message(chat_id, msg, reply_markup=get_main_keyboard(session))
999
+
1000
+ async def handle_playlist_config_interaction(chat_id: int, message_id: int):
1001
+ session = get_user_session(chat_id)
1002
+ playlist = session.get('input_url_playlist', [])
1003
+ msg = ""
1004
+ if playlist:
1005
+ plist_str = "\n".join([f"{i+1}. `{url}`" for i, url in enumerate(playlist)])
1006
+ msg = f"*Current Playlist ({len(playlist)} items):*\n{plist_str}\n\nUse `/playlist add <url>`, `/playlist remove <index>`, `/playlist clear`."
1007
+ else:
1008
+ msg = "Playlist is empty. Use `/playlist add <url>` to add your first stream input."
1009
+ return edit_message_text(chat_id, message_id, msg + "\n\n" + compose_status_message(chat_id), reply_markup=get_main_keyboard(session))
1010
+
1011
+
1012
+ async def handle_logo_data_from_telegram(chat_id: int, message: dict):
1013
+ """
1014
+ This function is a placeholder.
1015
+ To get file bytes, the bot needs to:
1016
+ 1. Get `file_id` from `message` (photo or document).
1017
+ 2. Call Telegram API's `getFile` method with `file_id` to get a `file_path`.
1018
+ 3. Download the file content from `https://api.telegram.org/file/bot<YOUR_BOT_TOKEN>/<file_path>`.
1019
+ This webhook setup cannot make these outgoing API calls.
1020
+ If you are using a library like `python-telegram-bot` which handles the webhook and provides
1021
+ the downloaded file bytes directly (e.g. via `message.effective_attachment.download_as_bytearray()`),
1022
+ then you would use that.
1023
+
1024
+ For this example, we'll simulate that the bytes are somehow obtained.
1025
+ """
1026
+ session = get_user_session(chat_id)
1027
+ lock = session_locks[chat_id]
1028
+
1029
+ # --- SIMULATION ---
1030
+ # In a real bot that can make API calls or using a full bot framework:
1031
+ # actual_logo_bytes = await download_file_from_telegram(file_id)
1032
+ # actual_mime_type = message["document"].get("mime_type") or "image/png" (if photo)
1033
+ # actual_filename = message["document"].get("file_name") or f"photo_logo_{chat_id}.png"
1034
+ # For now, we can't implement the download here. User will be told it's conceptual.
1035
+
1036
+ # Simulate receiving some dummy data
1037
+ dummy_logo_bytes = b"dummy_logo_data_placeholder_not_a_real_image"
1038
+ dummy_mime_type = "image/png"
1039
+ dummy_filename = f"concept_logo_{chat_id}.png"
1040
+ # --- END SIMULATION ---
1041
+
1042
+ # Check file size (conceptual, as we don't have real bytes here)
1043
+ MAX_LOGO_SIZE_BYTES = 1 * 1024 * 1024 # 1 MB
1044
+ # if len(actual_logo_bytes) > MAX_LOGO_SIZE_BYTES:
1045
+ # return send_message(chat_id, f"Logo is too large (max {MAX_LOGO_SIZE_BYTES/1024/1024:.1f} MB). Please upload a smaller one.")
1046
+
1047
+ with lock:
1048
+ session['logo_data_bytes'] = dummy_logo_bytes # actual_logo_bytes
1049
+ session['logo_mime_type'] = dummy_mime_type # actual_mime_type
1050
+ session['logo_original_filename'] = dummy_filename # actual_filename
1051
+ session['logo_enabled'] = True
1052
+ session['current_step'] = None
1053
+
1054
+ # save_user_settings_to_file(chat_id) # REMOVED
1055
+ append_user_live_log(chat_id, f"CONCEPTUAL: Logo data for '{dummy_filename}' stored in memory.")
1056
+ return send_message(chat_id, f"Conceptual: Logo '{dummy_filename}' data stored in memory and enabled! (Actual file download/processing requires full bot capabilities beyond this webhook example.)\nLogo settings (position, etc.) are in Advanced Settings.", reply_markup=get_main_keyboard(session))
1057
+
1058
+ async def handle_logo_config_interaction(chat_id: int, message_id: int):
1059
+ session = get_user_session(chat_id)
1060
+ msg_parts = ["*Logo Configuration (In-Memory):*"]
1061
+ if session.get('logo_data_bytes') and session.get('logo_enabled'):
1062
+ msg_parts.append(f"Status: Enabled (`{session.get('logo_original_filename', 'N/A')}`)")
1063
+ msg_parts.append(f"Position: `{session.get('logo_position')}` (Change in Advanced Settings)")
1064
+ msg_parts.append(f"Scale: `{session.get('logo_scale')}` (Change in Advanced Settings)")
1065
+ msg_parts.append(f"Opacity: `{session.get('logo_opacity')}` (Change in Advanced Settings)")
1066
+ msg_parts.append("\nTo change logo, send /set_logo then upload a new image.")
1067
+ msg_parts.append("To disable: `/configure_logo enabled false` (conceptual command).")
1068
+ elif session.get('logo_data_bytes') and not session.get('logo_enabled'):
1069
+ msg_parts.append(f"Status: Disabled (`{session.get('logo_original_filename', 'N/A')}`)")
1070
+ msg_parts.append("To enable: `/configure_logo enabled true` (conceptual command).")
1071
+ else:
1072
+ msg_parts.append("No logo uploaded/stored. Send /set_logo then upload an image.")
1073
+
1074
+ return edit_message_text(chat_id, message_id, "\n".join(msg_parts) + "\n\n" + compose_status_message(chat_id), reply_markup=get_main_keyboard(session))
1075
+
1076
+
1077
+ def schedule_stream_job(chat_id: int, trigger_time: datetime.datetime, job_name: str = None):
1078
+ session = get_user_session(chat_id)
1079
+ job_id = f"stream_{chat_id}_{int(trigger_time.timestamp())}"
1080
+ if not job_name: job_name = f"Scheduled Stream for {chat_id} at {trigger_time.strftime('%Y-%m-%d %H:%M')}"
1081
+
1082
+ scheduler.add_job(
1083
+ start_stream_for_scheduler,
1084
+ 'date',
1085
+ run_date=trigger_time,
1086
+ args=[chat_id],
1087
+ id=job_id,
1088
+ name=job_name,
1089
+ replace_existing=True
1090
+ )
1091
+ append_user_live_log(chat_id, f"Stream scheduled (in-memory): {job_name} (ID: {job_id}) at {trigger_time.strftime('%Y-%m-%d %H:%M:%S %Z')}")
1092
+ logger.info(f"[Chat {chat_id}] Job {job_id} scheduled (in-memory) for {trigger_time}")
1093
+
1094
+ def start_stream_for_scheduler(chat_id: int):
1095
+ logger.info(f"[Scheduler][Chat {chat_id}] Triggering scheduled stream (in-memory).")
1096
+ session = get_user_session(chat_id)
1097
+ # Ensure the asyncio event loop is available if start_stream_command_handler is async
1098
+ # This can be tricky when APScheduler runs in a separate thread.
1099
+ # A common pattern is to use asyncio.run_coroutine_threadsafe if the scheduler
1100
+ # has access to the main event loop, or just run the core logic synchronously if possible.
1101
+ # For simplicity, let's assume start_stream_command_handler can be adapted or called carefully.
1102
+
1103
+ # Simplified: this call will need an event loop if `start_stream_command_handler` is async.
1104
+ # If `start_stream_command_handler` is sync, it's fine.
1105
+ # Since it *is* async, this needs proper async handling from a sync thread.
1106
+ loop = asyncio.get_event_loop() # Get the main event loop stored at startup
1107
+ if loop and loop.is_running():
1108
+ asyncio.run_coroutine_threadsafe(start_stream_command_handler(chat_id, is_scheduled=True), loop)
1109
+ else: # Fallback for environments where loop might not be running in APScheduler's thread
1110
+ asyncio.run(start_stream_command_handler(chat_id, is_scheduled=True))
1111
+
1112
+ logger.info(f"[Scheduler][Chat {chat_id}] Scheduled stream start attempt initiated.")
1113
+
1114
+
1115
+ async def handle_schedule_command(chat_id: int, text: str):
1116
+ parts = text.split(maxsplit=3)
1117
+ if len(parts) < 3:
1118
+ return send_message(chat_id, "Usage: `/schedule YYYY-MM-DD HH:MM:SS [Optional Job Name]`\nExample: `/schedule 2024-12-31 23:55:00 NYE`\nTimezone is UTC. Schedules are in-memory and lost on restart.")
1119
+
1120
+ try:
1121
+ date_str, time_str = parts[1], parts[2]
1122
+ job_name = parts[3] if len(parts) > 3 else f"Stream for {chat_id}"
1123
+ trigger_time_naive = datetime.datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M:%S")
1124
+ trigger_time_utc = trigger_time_naive.replace(tzinfo=datetime.timezone.utc)
1125
+
1126
+ if trigger_time_utc <= datetime.datetime.now(datetime.timezone.utc):
1127
+ return send_message(chat_id, "Scheduled time must be in the future.")
1128
+
1129
+ schedule_stream_job(chat_id, trigger_time_utc, job_name)
1130
+ session = get_user_session(chat_id)
1131
+ return send_message(chat_id, f"Stream scheduled (in-memory) as '{job_name}' for {trigger_time_utc.strftime('%Y-%m-%d %H:%M:%S %Z')}. Current settings will be used.", reply_markup=get_main_keyboard(session))
1132
+ except ValueError as e:
1133
+ return send_message(chat_id, f"Invalid date/time format: {e}. Use YYYY-MM-DD HH:MM:SS.")
1134
+
1135
+ async def handle_schedule_config_interaction(chat_id: int, message_id: int):
1136
+ session = get_user_session(chat_id)
1137
+ jobs = scheduler.get_jobs()
1138
+ user_jobs = [job for job in jobs if job.id.startswith(f"stream_{chat_id}_")]
1139
+
1140
+ msg_parts = ["*Scheduled Streams (In-Memory - Lost on Restart):*"]
1141
+ if user_jobs:
1142
+ for job in user_jobs:
1143
+ run_time_str = job.next_run_time.strftime('%Y-%m-%d %H:%M:%S %Z') if job.next_run_time else "N/A"
1144
+ msg_parts.append(f"• `{job.name}` at `{run_time_str}` (ID: `{job.id}`)")
1145
+ msg_parts.append(f" To cancel: `/cancel_schedule {job.id}` (conceptual command).")
1146
+ else:
1147
+ msg_parts.append("No streams currently scheduled.")
1148
+ msg_parts.append("\nUse `/schedule YYYY-MM-DD HH:MM:SS [Name]` to schedule a new stream.")
1149
+
1150
+ return edit_message_text(chat_id, message_id, "\n".join(msg_parts) + "\n\n" + compose_status_message(chat_id), reply_markup=get_main_keyboard(session))
1151
+
1152
+
1153
+ async def start_stream_command_handler(chat_id: int, message_id_to_edit: int = None, is_scheduled: bool = False):
1154
+ session = get_user_session(chat_id)
1155
+ lock = session_locks[chat_id]
1156
+ response = None
1157
+
1158
+ with lock:
1159
+ if session['streaming_state'] in ["streaming", "paused", "starting"]:
1160
+ msg = f"A stream is already active ({session['streaming_state']})."
1161
+ if message_id_to_edit: response = edit_message_text(chat_id, message_id_to_edit, msg + "\n" + compose_status_message(chat_id), reply_markup=get_main_keyboard(session), parse_mode="HTML")
1162
+ else: response = send_message(chat_id, msg, reply_markup=get_main_keyboard(session))
1163
+ return response
1164
+
1165
+ if not session.get('input_url_playlist') or not session.get('output_url'):
1166
+ msg = "Input playlist or Output URL is not set. Please configure first using /settings or menu."
1167
+ if message_id_to_edit: response = edit_message_text(chat_id, message_id_to_edit, msg + "\n" + compose_status_message(chat_id), reply_markup=get_main_keyboard(session), parse_mode="HTML")
1168
+ else: response = send_message(chat_id, msg, reply_markup=get_main_keyboard(session))
1169
+ return response
1170
+
1171
+ session['streaming_state'] = "starting"
1172
+ session['error_notification_user'] = ""
1173
+
1174
+ # save_user_settings_to_file(chat_id) # REMOVED
1175
+
1176
+ thread = threading.Thread(target=stream_engine_thread_target, args=(chat_id,), name=f"StreamThread-{chat_id}", daemon=True)
1177
+ session['stream_thread_ref'] = thread
1178
+ thread.start()
1179
+ append_user_live_log(chat_id, "Stream thread initiated.")
1180
+
1181
+ await asyncio.sleep(0.2)
1182
+
1183
+ status_msg = compose_status_message(chat_id)
1184
+ if is_scheduled and not message_id_to_edit:
1185
+ logger.info(f"[Chat {chat_id}] Scheduled stream started. Status: {status_msg[:100]}")
1186
+ # Conceptual proactive message:
1187
+ # await send_proactive_message(chat_id, "Your scheduled stream is now starting!\n" + status_msg)
1188
+ return {"status": "scheduled_stream_started_silently_inmemory"}
1189
+ elif message_id_to_edit:
1190
+ response = edit_message_text(chat_id, message_id_to_edit, "Stream starting...\n" + status_msg, reply_markup=get_main_keyboard(session), parse_mode="HTML")
1191
+ else:
1192
+ response = send_message(chat_id, "Stream starting...\n" + status_msg, reply_markup=get_main_keyboard(session), parse_mode="HTML")
1193
+ return response
1194
+
1195
+
1196
+ async def stream_pause_handler(chat_id: int, message_id: int):
1197
+ session = get_user_session(chat_id)
1198
+ lock = session_locks[chat_id]
1199
+ with lock:
1200
+ if session['streaming_state'] == "streaming":
1201
+ session['streaming_state'] = "paused"
1202
+ append_user_live_log(chat_id, "Stream paused by user.")
1203
+ msg = "Stream paused."
1204
+ else:
1205
+ msg = f"Stream is not streaming (state: {session['streaming_state']}). Cannot pause."
1206
+ return edit_message_text(chat_id, message_id, msg + "\n" + compose_status_message(chat_id), reply_markup=get_main_keyboard(session), parse_mode="HTML")
1207
+
1208
+ async def stream_resume_handler(chat_id: int, message_id: int):
1209
+ session = get_user_session(chat_id)
1210
+ lock = session_locks[chat_id]
1211
+ with lock:
1212
+ if session['streaming_state'] == "paused":
1213
+ session['streaming_state'] = "streaming"
1214
+ append_user_live_log(chat_id, "Stream resumed by user.")
1215
+ msg = "Stream resumed."
1216
+ else:
1217
+ msg = f"Stream is not paused (state: {session['streaming_state']}). Cannot resume."
1218
+ return edit_message_text(chat_id, message_id, msg + "\n" + compose_status_message(chat_id), reply_markup=get_main_keyboard(session), parse_mode="HTML")
1219
+
1220
+ async def stream_abort_handler(chat_id: int, message_id_to_edit: int = None):
1221
+ session = get_user_session(chat_id)
1222
+ lock = session_locks[chat_id]
1223
+ thread_to_join = None
1224
+ msg = ""
1225
+
1226
+ with lock:
1227
+ if session['streaming_state'] in ["streaming", "paused", "starting"]:
1228
+ session['streaming_state'] = "stopping"
1229
+ thread_to_join = session.get('stream_thread_ref')
1230
+ append_user_live_log(chat_id, "Stream abort sequence initiated by user.")
1231
+ msg = "Stream aborting..."
1232
+ else:
1233
+ msg = f"No active stream to abort (state: {session['streaming_state']})."
1234
+ if message_id_to_edit: return edit_message_text(chat_id, message_id_to_edit, msg + "\n" + compose_status_message(chat_id), reply_markup=get_main_keyboard(session), parse_mode="HTML")
1235
+ return send_message(chat_id, msg, reply_markup=get_main_keyboard(session))
1236
+
1237
+ if thread_to_join and thread_to_join.is_alive():
1238
+ logger.info(f"[Chat {chat_id}] Waiting for stream thread {thread_to_join.name} to join...")
1239
+ thread_to_join.join(timeout=7.0)
1240
+ if thread_to_join.is_alive():
1241
+ append_user_live_log(chat_id, "Warning: Stream thread did not terminate cleanly after abort signal.")
1242
+ logger.warning(f"[Chat {chat_id}] Stream thread {thread_to_join.name} still alive after join timeout.")
1243
+ else:
1244
+ append_user_live_log(chat_id, "Stream thread terminated.")
1245
+
1246
+ with lock:
1247
+ if session['streaming_state'] == "stopping":
1248
+ session['streaming_state'] = "stopped"
1249
+ session['stream_thread_ref'] = None
1250
+
1251
+ final_status_msg = "Stream aborted.\n" + compose_status_message(chat_id)
1252
+ if message_id_to_edit:
1253
+ return edit_message_text(chat_id, message_id_to_edit, final_status_msg, reply_markup=get_main_keyboard(session), parse_mode="HTML")
1254
+ else:
1255
+ return send_message(chat_id, final_status_msg, reply_markup=get_main_keyboard(session), parse_mode="HTML")
1256
+
1257
+
1258
+ def get_help_text():
1259
+ return (
1260
+ f"*Advanced Stream Bot v{APP_VERSION} (In-Memory Edition) Help*\n\n"
1261
+ "⚠️ *IMPORTANT: All settings, uploaded logos, and schedules are stored in memory "
1262
+ "and will be LOST if the bot restarts due to environment restrictions.*\n\n"
1263
+ "Use the interactive menu buttons or the following commands:\n"
1264
+ "*/start* or */menu* - Show main menu and status.\n"
1265
+ "*/settings* - Start advanced configuration.\n"
1266
+ "*/stream* - Start stream with current settings.\n"
1267
+ "*/playlist add <url>* - Add URL to playlist.\n"
1268
+ "*/playlist remove <index|last>* - Remove URL from playlist.\n"
1269
+ "*/playlist clear* - Clear entire playlist.\n"
1270
+ "*/playlist show* - Display current playlist.\n"
1271
+ "*/set_logo* - Prompts to upload a logo image (stored in memory).\n"
1272
+ " (Logo settings like position, scale are in Advanced Settings via menu)\n"
1273
+ "*/schedule YYYY-MM-DD HH:MM:SS [Name]* - Schedule a stream (UTC time, in-memory).\n"
1274
+ " Example: `/schedule 2024-07-04 18:30:00 My Stream`\n"
1275
+ "*/status* - Show detailed stream status and config.\n"
1276
+ "*/abort* - Force stop the current stream.\n"
1277
+ "\n*While Streaming (via menu buttons):*\n"
1278
+ "• Pause/Resume Stream.\n"
1279
+ "• Abort Stream.\n"
1280
+ "• Status Update (refreshes message).\n"
1281
+ "• Stop Loop Gracefully (for infinite loops, current iteration completes).\n"
1282
+ "\n*Configuration Conversation:*\n"
1283
+ "When in a setup process (e.g., after /settings), reply to prompts.\n"
1284
+ "Type `/cancel` to exit setup anytime.\n"
1285
+ )
1286
+
1287
+ # --- FastAPI Webhook Endpoint ---
1288
+ import asyncio
1289
+
1290
+ @app.on_event("startup")
1291
+ async def startup_event():
1292
+ # load_all_sessions_on_startup() # REMOVED
1293
+ if not scheduler.running:
1294
+ scheduler.start()
1295
+ logger.info("APScheduler started (using MemoryJobStore).")
1296
+ logger.info(f"FastAPI application startup complete. Version: {APP_VERSION}. Data is IN-MEMORY.")
1297
+ logger.warning("IMPORTANT: All user settings, logos, and schedules will be lost on bot restart.")
1298
+
1299
+ @app.on_event("shutdown")
1300
+ async def shutdown_event():
1301
+ if scheduler.running:
1302
+ scheduler.shutdown()
1303
+ logger.info("APScheduler shutdown.")
1304
+ # No settings to save to file
1305
+ logger.info("FastAPI application shutdown. In-memory data will be lost.")
1306
+
1307
+
1308
+ @app.post("/webhook")
1309
+ async def telegram_webhook_endpoint(request: Request):
1310
+ try:
1311
+ update = await request.json()
1312
+ # logger.debug(f"Webhook received: {update}") # Can be too verbose
1313
+
1314
+ response_data = await handle_telegram_update(update)
1315
+
1316
+ if isinstance(response_data, list):
1317
+ main_response = None
1318
+ for r_item in response_data:
1319
+ if r_item and r_item.get("method") in ["sendMessage", "editMessageText", "sendPhoto", "sendDocument"]: # Added more types
1320
+ main_response = r_item
1321
+ break
1322
+ if not main_response:
1323
+ main_response = response_data[0] if response_data else {"status": "ok", "message": "Empty response list from handler"}
1324
+ return main_response
1325
+
1326
+ return response_data
1327
+
1328
+ except json.JSONDecodeError:
1329
+ logger.error("Invalid JSON received in webhook.")
1330
+ raise HTTPException(status_code=400, detail="Invalid JSON payload")
1331
+ except Exception as e:
1332
+ logger.error(f"Error processing webhook: {e}", exc_info=True)
1333
+ return {"status": "ok", "message": "Internal server error processing update."} # Ack to Telegram
1334
+
1335
+ @app.get("/")
1336
+ async def root():
1337
+ return {"message": f"Advanced Stream Bot v{APP_VERSION} (In-Memory Edition) is running. Webhook is at /webhook."}
1338
+
1339
 
1340
+ # --- Main Execution ---
1341
+ # if __name__ == "__main__":
1342
+ # import uvicorn
1343
+ # logger.info(f"Starting Advanced Stream Bot v{APP_VERSION} (In-Memory Edition)...")
1344
+ # logger.warning("This version stores all data in memory. NO DATA WILL PERSIST ACROSS RESTARTS.")
1345
+ # uvicorn.run("__main__:app", host="127.0.0.1", port=8000, reload=True)