tvtui 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tvtui.py ADDED
@@ -0,0 +1,1687 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import curses
4
+ import datetime as dt
5
+ import json
6
+ import os
7
+ import re
8
+ import shlex
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import threading
13
+ import time
14
+ import urllib.request
15
+ import urllib.parse
16
+ import xml.etree.ElementTree as ET
17
+ from dataclasses import dataclass
18
+ from typing import Dict, Iterable, List, Optional, Tuple
19
+
20
+ VERSION = "2.0.0"
21
+
22
+ DEFAULT_EPG_URL = "https://raw.githubusercontent.com/doms9/iptv/refs/heads/default/EPG/TV.xml"
23
+ DEFAULT_BASE_M3U_URL = "https://s.id/d9Base"
24
+ DEFAULT_STREAMED_BASE = "https://raw.githubusercontent.com/doms9/iptv/default/M3U8"
25
+
26
+ CACHE_MAX_AGE_SECS = 3600
27
+ EPG_MAX_AGE_SECS = 43200
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Channel:
32
+ name: str
33
+ url: str
34
+ tvg_id: str
35
+ kind: str = "live"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Program:
40
+ title: str
41
+ start: dt.datetime
42
+ stop: dt.datetime
43
+ desc: str = ""
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class PlayerConfig:
48
+ name: str
49
+ args: List[str]
50
+ custom_command: List[str]
51
+ subs_on_args: List[str]
52
+ subs_off_args: List[str]
53
+ vlc_sub_track: int
54
+
55
+
56
+ def ensure_dirs(config_dir: str, cache_dir: str) -> None:
57
+ os.makedirs(config_dir, exist_ok=True)
58
+ os.makedirs(cache_dir, exist_ok=True)
59
+
60
+
61
+ def config_paths() -> Tuple[str, str, str, str, str]:
62
+ config_dir = os.path.expanduser("~/.config/tvtui")
63
+ cache_dir = os.path.expanduser("~/.cache/tvtui")
64
+ favorites_file = os.path.join(config_dir, "favorites.tsv")
65
+ history_file = os.path.join(config_dir, "history.log")
66
+ epg_cache = os.path.join(config_dir, "epg.xml")
67
+ channels_cache = os.path.join(cache_dir, "channels.json")
68
+ return config_dir, cache_dir, favorites_file, history_file, epg_cache, channels_cache
69
+
70
+
71
+ def http_get(url: str, timeout: int = 30) -> bytes:
72
+ req = urllib.request.Request(url, headers={"User-Agent": "tvTUI/1.0"})
73
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
74
+ return resp.read()
75
+
76
+
77
+ def http_get_json(url: str, timeout: int = 30) -> Tuple[object, str]:
78
+ try:
79
+ data = http_get(url, timeout=timeout)
80
+ return json.loads(data.decode("utf-8", errors="replace")), ""
81
+ except Exception:
82
+ return None, "Unable to fetch Xtream data."
83
+
84
+
85
+ def xtream_api_url(base_url: str, username: str, password: str, action: str, **params) -> str:
86
+ base = base_url.rstrip("/")
87
+ query = {"username": username, "password": password, "action": action}
88
+ query.update({k: v for k, v in params.items() if v is not None})
89
+ return f"{base}/player_api.php?{urllib.parse.urlencode(query)}"
90
+
91
+
92
+ def update_epg_cache(epg_cache: str, epg_url: str) -> Tuple[bool, str]:
93
+ if os.path.exists(epg_cache):
94
+ age = time.time() - os.path.getmtime(epg_cache)
95
+ if age < EPG_MAX_AGE_SECS:
96
+ return True, ""
97
+ try:
98
+ data = http_get(epg_url, timeout=60)
99
+ except Exception:
100
+ return False, "Unable to download EPG data."
101
+
102
+ tmp = epg_cache + ".tmp"
103
+ with open(tmp, "wb") as f:
104
+ f.write(data)
105
+ os.replace(tmp, epg_cache)
106
+ return True, ""
107
+
108
+
109
+ def parse_epg_time(raw: str) -> Optional[dt.datetime]:
110
+ if not raw:
111
+ return None
112
+ raw = raw.strip()
113
+ base = raw[:14]
114
+ try:
115
+ base_dt = dt.datetime.strptime(base, "%Y%m%d%H%M%S")
116
+ except ValueError:
117
+ return None
118
+ tz = dt.timezone.utc
119
+ if len(raw) > 14:
120
+ offset = raw[14:].strip()
121
+ m = re.match(r"([+-])(\d{2})(\d{2})", offset)
122
+ if m:
123
+ sign = 1 if m.group(1) == "+" else -1
124
+ hours = int(m.group(2))
125
+ minutes = int(m.group(3))
126
+ delta = dt.timedelta(hours=hours, minutes=minutes)
127
+ tz = dt.timezone(sign * delta)
128
+ return base_dt.replace(tzinfo=tz).astimezone(dt.timezone.utc)
129
+
130
+
131
+ def build_program_map(epg_cache: str, now: Optional[dt.datetime] = None) -> Dict[str, Program]:
132
+ if not os.path.exists(epg_cache):
133
+ return {}
134
+ now = now or dt.datetime.now(dt.timezone.utc)
135
+ programs: Dict[str, Program] = {}
136
+ try:
137
+ for _, elem in ET.iterparse(epg_cache, events=("end",)):
138
+ if elem.tag != "programme":
139
+ continue
140
+ channel_id = elem.attrib.get("channel", "")
141
+ if not channel_id or channel_id in programs:
142
+ elem.clear()
143
+ continue
144
+ start = parse_epg_time(elem.attrib.get("start", ""))
145
+ stop = parse_epg_time(elem.attrib.get("stop", ""))
146
+ if not start or not stop:
147
+ elem.clear()
148
+ continue
149
+ if start <= now < stop:
150
+ title = elem.findtext("title") or "Live Programming"
151
+ desc = elem.findtext("desc") or ""
152
+ programs[channel_id] = Program(
153
+ title=title, start=start, stop=stop, desc=desc
154
+ )
155
+ elem.clear()
156
+ except ET.ParseError:
157
+ return {}
158
+ return programs
159
+
160
+
161
+ def build_epg_index(epg_cache: str, now: Optional[dt.datetime] = None) -> Dict[str, List[Program]]:
162
+ if not os.path.exists(epg_cache):
163
+ return {}
164
+ now = now or dt.datetime.now(dt.timezone.utc)
165
+ index: Dict[str, List[Program]] = {}
166
+ try:
167
+ for _, elem in ET.iterparse(epg_cache, events=("end",)):
168
+ if elem.tag != "programme":
169
+ continue
170
+ channel_id = elem.attrib.get("channel", "")
171
+ if not channel_id:
172
+ elem.clear()
173
+ continue
174
+ start = parse_epg_time(elem.attrib.get("start", ""))
175
+ stop = parse_epg_time(elem.attrib.get("stop", ""))
176
+ if not start or not stop:
177
+ elem.clear()
178
+ continue
179
+ if stop < now:
180
+ elem.clear()
181
+ continue
182
+ title = elem.findtext("title") or "Live Programming"
183
+ desc = elem.findtext("desc") or ""
184
+ index.setdefault(channel_id, []).append(
185
+ Program(title=title, start=start, stop=stop, desc=desc)
186
+ )
187
+ elem.clear()
188
+ except ET.ParseError:
189
+ return {}
190
+ for channel_id, items in index.items():
191
+ items.sort(key=lambda p: p.start)
192
+ index[channel_id] = items[:6]
193
+ return index
194
+
195
+
196
+ def parse_m3u(content: str) -> Iterable[Channel]:
197
+ name = ""
198
+ tvg_id = ""
199
+ for line in content.splitlines():
200
+ line = line.strip()
201
+ if not line:
202
+ continue
203
+ if line.startswith("#EXTINF"):
204
+ tvg_id = ""
205
+ name = ""
206
+ m_name = re.search(r'tvg-name="([^"]+)"', line, re.IGNORECASE)
207
+ if m_name:
208
+ name = m_name.group(1)
209
+ else:
210
+ if "," in line:
211
+ name = line.split(",", 1)[1].strip()
212
+ m_id = re.search(r'tvg-id="([^"]+)"', line, re.IGNORECASE)
213
+ if m_id:
214
+ tvg_id = m_id.group(1)
215
+ elif line.startswith("http") and name:
216
+ yield Channel(name=name, url=line, tvg_id=tvg_id)
217
+ name = ""
218
+ tvg_id = ""
219
+
220
+
221
+ def fetch_m3u(url: str) -> Tuple[List[Channel], str]:
222
+ try:
223
+ data = http_get(url)
224
+ except Exception:
225
+ return [], "Unable to download channel list."
226
+ content = data.decode("utf-8", errors="replace")
227
+ return list(parse_m3u(content)), ""
228
+
229
+
230
+ def load_channels_cache(channels_cache: str) -> Optional[List[Channel]]:
231
+ if not os.path.exists(channels_cache):
232
+ return None
233
+ if time.time() - os.path.getmtime(channels_cache) > CACHE_MAX_AGE_SECS:
234
+ return None
235
+ try:
236
+ with open(channels_cache, "r", encoding="utf-8") as f:
237
+ payload = json.load(f)
238
+ channels = [Channel(**item) for item in payload.get("channels", [])]
239
+ return channels
240
+ except (OSError, json.JSONDecodeError, TypeError):
241
+ return None
242
+
243
+
244
+ def save_channels_cache(channels_cache: str, channels: List[Channel]) -> None:
245
+ payload = {"channels": [c.__dict__ for c in channels]}
246
+ with open(channels_cache, "w", encoding="utf-8") as f:
247
+ json.dump(payload, f)
248
+
249
+
250
+ def get_iptv_channels(channels_cache: str, base_m3u_url: str) -> Tuple[List[Channel], str]:
251
+ cached = load_channels_cache(channels_cache)
252
+ if cached is not None:
253
+ return cached, ""
254
+ channels, error = fetch_m3u(base_m3u_url)
255
+ if channels:
256
+ save_channels_cache(channels_cache, channels)
257
+ return channels, error
258
+
259
+
260
+ def get_fallback_channels() -> List[Channel]:
261
+ return [
262
+ Channel(
263
+ name="France 24",
264
+ url="https://static.france24.com/live/F24_EN_LO_HLS/live_web.m3u8",
265
+ tvg_id="",
266
+ ),
267
+ Channel(
268
+ name="CBS News",
269
+ url="https://cbsn-us.cbsnstream.cbsnews.com/out/v1/55a8648e8f134e82a470f83d562deeca/master.m3u8",
270
+ tvg_id="",
271
+ ),
272
+ Channel(
273
+ name="Red Bull TV",
274
+ url="https://rbmn-live.akamaized.net/hls/live/590964/BoRB-AT/master.m3u8",
275
+ tvg_id="",
276
+ ),
277
+ Channel(
278
+ name="Pluto TV Movies",
279
+ url="https://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5cb0cae7a461406ffe3f5213/master.m3u8",
280
+ tvg_id="",
281
+ ),
282
+ ]
283
+
284
+
285
+ def get_category_channels(category_id: str, streamed_base: str) -> Tuple[List[Channel], str]:
286
+ if category_id == "events":
287
+ return fetch_m3u(f"{streamed_base}/events.m3u8")
288
+ if category_id == "tv":
289
+ return fetch_m3u(f"{streamed_base}/TV.m3u8")
290
+ if category_id == "base":
291
+ return fetch_m3u(f"{streamed_base}/base.m3u8")
292
+ return [], ""
293
+
294
+
295
+ def xtream_get_categories(
296
+ base_url: str, username: str, password: str, kind: str
297
+ ) -> Tuple[List[Tuple[str, str]], str]:
298
+ action_map = {
299
+ "live": "get_live_categories",
300
+ "movie": "get_vod_categories",
301
+ "series": "get_series_categories",
302
+ }
303
+ action = action_map.get(kind)
304
+ if not action:
305
+ return [], "Unknown Xtream category type."
306
+ url = xtream_api_url(base_url, username, password, action)
307
+ data, err = http_get_json(url)
308
+ if err or not isinstance(data, list):
309
+ return [], err or "Invalid Xtream category response."
310
+ items = []
311
+ for row in data:
312
+ cat_id = str(row.get("category_id", "")).strip()
313
+ name = str(row.get("category_name", "")).strip()
314
+ if cat_id and name:
315
+ items.append((name, cat_id))
316
+ return items, ""
317
+
318
+
319
+ def xtream_stream_url(
320
+ base_url: str, username: str, password: str, kind: str, stream_id: str, ext: str
321
+ ) -> str:
322
+ base = base_url.rstrip("/")
323
+ if kind == "movie":
324
+ suffix = f"{stream_id}.{ext or 'mp4'}"
325
+ return f"{base}/movie/{username}/{password}/{suffix}"
326
+ if kind == "series":
327
+ suffix = f"{stream_id}.{ext or 'mp4'}"
328
+ return f"{base}/series/{username}/{password}/{suffix}"
329
+ return f"{base}/live/{username}/{password}/{stream_id}.m3u8"
330
+
331
+
332
+ def xtream_get_streams(
333
+ base_url: str,
334
+ username: str,
335
+ password: str,
336
+ kind: str,
337
+ category_id: Optional[str] = None,
338
+ ) -> Tuple[List[Channel], str]:
339
+ action_map = {
340
+ "live": "get_live_streams",
341
+ "movie": "get_vod_streams",
342
+ "series": "get_series",
343
+ }
344
+ action = action_map.get(kind)
345
+ if not action:
346
+ return [], "Unknown Xtream stream type."
347
+ url = xtream_api_url(
348
+ base_url,
349
+ username,
350
+ password,
351
+ action,
352
+ category_id=category_id,
353
+ )
354
+ data, err = http_get_json(url)
355
+ if err or not isinstance(data, list):
356
+ return [], err or "Invalid Xtream stream response."
357
+ channels = []
358
+ for row in data:
359
+ name = str(row.get("name", "")).strip()
360
+ if not name:
361
+ continue
362
+ if kind == "series":
363
+ series_id = str(row.get("series_id", "")).strip()
364
+ if not series_id:
365
+ continue
366
+ channels.append(Channel(name=name, url="", tvg_id=series_id, kind="series"))
367
+ else:
368
+ stream_id = str(row.get("stream_id", "")).strip()
369
+ if not stream_id:
370
+ continue
371
+ ext = str(row.get("container_extension", "")).strip()
372
+ url = xtream_stream_url(base_url, username, password, kind, stream_id, ext)
373
+ tvg_id = str(row.get("epg_channel_id", "")).strip() if kind == "live" else ""
374
+ channels.append(Channel(name=name, url=url, tvg_id=tvg_id, kind=kind))
375
+ return channels, ""
376
+
377
+
378
+ def xtream_get_series_episodes(
379
+ base_url: str, username: str, password: str, series_id: str
380
+ ) -> Tuple[List[Tuple[str, str]], str]:
381
+ url = xtream_api_url(
382
+ base_url, username, password, "get_series_info", series_id=series_id
383
+ )
384
+ data, err = http_get_json(url)
385
+ if err or not isinstance(data, dict):
386
+ return [], err or "Invalid Xtream series response."
387
+ episodes = []
388
+ for season, items in (data.get("episodes") or {}).items():
389
+ for ep in items or []:
390
+ ep_id = str(ep.get("id", "")).strip()
391
+ title = str(ep.get("title", "")).strip() or "Episode"
392
+ num = str(ep.get("episode_num", "")).strip()
393
+ ext = str(ep.get("container_extension", "")).strip()
394
+ if not ep_id:
395
+ continue
396
+ label = f"S{season}E{num} {title}".strip()
397
+ url = xtream_stream_url(base_url, username, password, "series", ep_id, ext)
398
+ episodes.append((label, url))
399
+ return episodes, ""
400
+
401
+
402
+ def load_favorites(favorites_file: str) -> List[Channel]:
403
+ favorites = []
404
+ if not os.path.exists(favorites_file):
405
+ return favorites
406
+ with open(favorites_file, "r", encoding="utf-8") as f:
407
+ for line in f:
408
+ line = line.rstrip("\n")
409
+ if not line:
410
+ continue
411
+ parts = line.split("\t")
412
+ if len(parts) >= 2:
413
+ name = parts[0]
414
+ url = parts[1]
415
+ tvg_id = parts[2] if len(parts) > 2 else ""
416
+ favorites.append(Channel(name=name, url=url, tvg_id=tvg_id))
417
+ return favorites
418
+
419
+
420
+ def merge_with_favorites(channels: List[Channel], favorites: List[Channel]) -> List[Channel]:
421
+ seen = {c.url for c in channels}
422
+ merged = list(channels)
423
+ for fav in favorites:
424
+ if fav.url not in seen:
425
+ merged.append(fav)
426
+ return merged
427
+
428
+
429
+ def save_favorites(favorites_file: str, favorites: List[Channel]) -> None:
430
+ tmp = favorites_file + ".tmp"
431
+ with open(tmp, "w", encoding="utf-8") as f:
432
+ for fav in favorites:
433
+ f.write(f"{fav.name}\t{fav.url}\t{fav.tvg_id}\n")
434
+ os.replace(tmp, favorites_file)
435
+
436
+
437
+ def toggle_favorite(favorites_file: str, channel: Channel) -> None:
438
+ if not channel.url:
439
+ return
440
+ favorites = load_favorites(favorites_file)
441
+ existing = [f for f in favorites if f.url == channel.url]
442
+ if existing:
443
+ favorites = [f for f in favorites if f.url != channel.url]
444
+ else:
445
+ favorites.append(channel)
446
+ save_favorites(favorites_file, favorites)
447
+
448
+
449
+ def append_history(history_file: str, channel: Channel) -> None:
450
+ stamp = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
451
+ with open(history_file, "a", encoding="utf-8") as f:
452
+ f.write(f"{stamp} - {channel.name}\n")
453
+
454
+
455
+ class Player:
456
+ def __init__(self, config: PlayerConfig) -> None:
457
+ self.proc: Optional[subprocess.Popen] = None
458
+ self.config = config
459
+
460
+ def play(self, url: str, subs_enabled: bool) -> None:
461
+ self.stop()
462
+ cmd: Optional[List[str]] = None
463
+ name = self.config.name
464
+ if name == "auto":
465
+ if shutil.which("mpv"):
466
+ name = "mpv"
467
+ elif shutil.which("vlc"):
468
+ name = "vlc"
469
+ if name == "mpv" and shutil.which("mpv"):
470
+ cmd = ["mpv"] + self.config.args
471
+ cmd += self.config.subs_on_args if subs_enabled else self.config.subs_off_args
472
+ cmd.append(url)
473
+ elif name == "vlc" and shutil.which("vlc"):
474
+ cmd = ["vlc"] + self.config.args
475
+ if subs_enabled:
476
+ cmd.append(f"--sub-track={self.config.vlc_sub_track}")
477
+ else:
478
+ cmd.append("--sub-track=0")
479
+ cmd.append(url)
480
+ elif name == "custom" and self.config.custom_command:
481
+ cmd = list(self.config.custom_command)
482
+ cmd += self.config.subs_on_args if subs_enabled else self.config.subs_off_args
483
+ if not any("{url}" in part for part in cmd):
484
+ cmd.append(url)
485
+ cmd = [part.replace("{url}", url) for part in cmd]
486
+ if cmd is None:
487
+ return
488
+ self.proc = subprocess.Popen(
489
+ cmd,
490
+ stdout=subprocess.DEVNULL,
491
+ stderr=subprocess.DEVNULL,
492
+ )
493
+
494
+ def stop(self) -> None:
495
+ if self.proc and self.proc.poll() is None:
496
+ self.proc.terminate()
497
+ try:
498
+ self.proc.wait(timeout=2)
499
+ except subprocess.TimeoutExpired:
500
+ self.proc.kill()
501
+ self.proc = None
502
+
503
+
504
+ def format_program(programs: Dict[str, Program], channel: Channel) -> str:
505
+ if channel.tvg_id and channel.tvg_id in programs:
506
+ return programs[channel.tvg_id].title
507
+ return "Live Programming"
508
+
509
+
510
+ def format_time_window(program: Program) -> str:
511
+ start = program.start.astimezone().strftime("%H:%M")
512
+ stop = program.stop.astimezone().strftime("%H:%M")
513
+ return f"{start}-{stop}"
514
+
515
+
516
+ def category_label(name: str, use_emoji: bool) -> Tuple[str, str]:
517
+ upper = name.upper()
518
+ if any(token in upper for token in ("ESPN", "NFL", "NBA", "MLB", "SPORT")):
519
+ return ("⚽", "sports") if use_emoji else ("[SP]", "sports")
520
+ if any(token in upper for token in ("CNN", "NEWS", "BBC")):
521
+ return ("📰", "news") if use_emoji else ("[NW]", "news")
522
+ if any(token in upper for token in ("MTV", "MUSIC", "BET")):
523
+ return ("🎵", "music") if use_emoji else ("[MU]", "music")
524
+ if any(token in upper for token in ("CARTOON", "NICK", "DISNEY", "KIDS")):
525
+ return ("👶", "kids") if use_emoji else ("[KD]", "kids")
526
+ if any(token in upper for token in ("FOOD", "HGTV", "TRAVEL", "COOK")):
527
+ return ("🏠", "lifestyle") if use_emoji else ("[LIFE]", "lifestyle")
528
+ if any(token in upper for token in ("MOVIE", "CINEMA", "FILM")):
529
+ return ("🎬", "movies") if use_emoji else ("[MV]", "movies")
530
+ return ("📺", "tv") if use_emoji else ("[TV]", "tv")
531
+
532
+
533
+ def tag_color(category_key: str) -> int:
534
+ return {
535
+ "sports": 1,
536
+ "news": 2,
537
+ "music": 3,
538
+ "kids": 4,
539
+ "lifestyle": 5,
540
+ "movies": 6,
541
+ "tv": 7,
542
+ }.get(category_key, 7)
543
+
544
+
545
+ def program_listing(
546
+ epg_index: Dict[str, List[Program]], channel: Channel
547
+ ) -> List[Tuple[str, str, str, str]]:
548
+ if channel.kind != "live":
549
+ label = "VOD" if channel.kind == "movie" else "SER"
550
+ return [(label, "--:--", channel.name, "")]
551
+ if not channel.tvg_id or channel.tvg_id not in epg_index:
552
+ return [("NOW", "--:--", "Live Programming", "")]
553
+ now = dt.datetime.now(dt.timezone.utc)
554
+ items = epg_index[channel.tvg_id]
555
+ lines: List[Tuple[str, str, str, str]] = []
556
+ for item in items:
557
+ label = "NEXT"
558
+ if item.start <= now < item.stop:
559
+ label = "NOW"
560
+ lines.append((label, format_time_window(item), item.title, item.desc))
561
+ return lines[:5]
562
+
563
+
564
+ def clip_text(text: str, max_len: int) -> str:
565
+ text = " ".join(text.split())
566
+ if len(text) <= max_len:
567
+ return text
568
+ return text[: max(0, max_len - 1)] + "…"
569
+
570
+
571
+ def text_wrap(text: str, width: int) -> List[str]:
572
+ if width <= 0:
573
+ return []
574
+ words = text.split()
575
+ if not words:
576
+ return [""]
577
+ lines = []
578
+ current = words[0]
579
+ for word in words[1:]:
580
+ if len(current) + 1 + len(word) <= width:
581
+ current = f"{current} {word}"
582
+ else:
583
+ lines.append(current)
584
+ current = word
585
+ lines.append(current)
586
+ return lines
587
+
588
+
589
+ def apply_sort(
590
+ channels: List[Channel], sort_mode: str, use_emoji_tags: bool
591
+ ) -> List[Channel]:
592
+ if sort_mode == "name":
593
+ return sorted(channels, key=lambda c: c.name.lower())
594
+ if sort_mode == "category":
595
+ return sorted(
596
+ channels,
597
+ key=lambda c: (category_label(c.name, False)[1], c.name.lower()),
598
+ )
599
+ return channels
600
+
601
+
602
+ def render_screen(
603
+ stdscr: "curses._CursesWindow",
604
+ channels: List[Channel],
605
+ programs: Dict[str, Program],
606
+ epg_index: Dict[str, List[Program]],
607
+ favorites_set: set,
608
+ selected_index: int,
609
+ top_index: int,
610
+ query: str,
611
+ mode: str,
612
+ help_visible: bool,
613
+ search_mode: bool,
614
+ use_emoji_tags: bool,
615
+ desc_index: int,
616
+ status_message: str,
617
+ sort_mode: str,
618
+ content_mode: str,
619
+ ) -> None:
620
+ stdscr.erase()
621
+ height, width = stdscr.getmaxyx()
622
+ help_width = 28
623
+ show_help_panel = help_visible and width >= help_width + 20
624
+ list_width = width - help_width - 1 if show_help_panel else width
625
+ search_label = "search" if search_mode else "nav"
626
+ header = (
627
+ f"tvTUI {VERSION} | view: {mode} | content: {content_mode} | "
628
+ f"sort: {sort_mode} | channels: {len(channels)} | {search_label}: {query}"
629
+ )
630
+ if curses.has_colors():
631
+ stdscr.attron(curses.color_pair(8))
632
+ status = f" | {status_message}" if status_message else ""
633
+ stdscr.addnstr(0, 0, header + status, list_width - 1, curses.A_BOLD)
634
+ if curses.has_colors():
635
+ stdscr.attroff(curses.color_pair(8))
636
+ preview_height = 7
637
+ list_height = max(1, height - 2 - preview_height)
638
+ list_top = 1
639
+ list_bottom = list_top + list_height
640
+
641
+ hline = getattr(curses, "ACS_HLINE", ord("-"))
642
+ vline = getattr(curses, "ACS_VLINE", ord("|"))
643
+
644
+ for idx in range(list_height):
645
+ chan_index = top_index + idx
646
+ if chan_index >= len(channels):
647
+ break
648
+ chan = channels[chan_index]
649
+ fav_mark = "*" if chan.url in favorites_set else " "
650
+ label, category_key = category_label(chan.name, use_emoji_tags)
651
+ line = f"[{fav_mark}] {label} {chan.name}"
652
+ if chan_index == selected_index:
653
+ stdscr.attron(curses.A_REVERSE)
654
+ stdscr.addnstr(list_top + idx, 0, line, list_width - 1)
655
+ stdscr.attroff(curses.A_REVERSE)
656
+ else:
657
+ stdscr.addnstr(list_top + idx, 0, line, list_width - 1)
658
+ if curses.has_colors():
659
+ tag_pos = line.find(label)
660
+ if tag_pos >= 0:
661
+ stdscr.attron(curses.color_pair(tag_color(category_key)))
662
+ stdscr.addnstr(list_top + idx, tag_pos, label, len(label))
663
+ stdscr.attroff(curses.color_pair(tag_color(category_key)))
664
+
665
+ if channels:
666
+ selected = channels[selected_index]
667
+ if list_bottom < height:
668
+ stdscr.hline(list_bottom, 0, hline, list_width - 1)
669
+ if list_bottom + 1 < height:
670
+ stdscr.addnstr(
671
+ list_bottom + 1,
672
+ 0,
673
+ "Name:",
674
+ list_width - 1,
675
+ curses.A_BOLD,
676
+ )
677
+ stdscr.addnstr(
678
+ list_bottom + 1,
679
+ 6,
680
+ f"{selected.name}",
681
+ list_width - 7,
682
+ )
683
+ if list_bottom + 2 < height:
684
+ stdscr.addnstr(
685
+ list_bottom + 2,
686
+ 0,
687
+ "URL:",
688
+ list_width - 1,
689
+ curses.A_BOLD,
690
+ )
691
+ stdscr.addnstr(
692
+ list_bottom + 2,
693
+ 6,
694
+ f"{selected.url}",
695
+ list_width - 7,
696
+ )
697
+ if list_bottom + 3 < height:
698
+ stdscr.addnstr(list_bottom + 3, 0, "EPG:", list_width - 1, curses.A_BOLD)
699
+ epg_lines = program_listing(epg_index, selected)
700
+ desc_width = min(60, max(24, list_width // 2))
701
+ list_box_width = list_width - desc_width - 1
702
+ if list_box_width < 22:
703
+ desc_width = max(0, list_width - 23)
704
+ list_box_width = list_width - desc_width - 1
705
+ if desc_width <= 0 or list_box_width < 20:
706
+ list_box_width = list_width
707
+ desc_width = 0
708
+ for offset, item in enumerate(epg_lines[:5]):
709
+ row = list_bottom + 4 + offset
710
+ if row >= height:
711
+ break
712
+ label, time_window, title, _desc = item
713
+ stdscr.addnstr(row, 0, " ", list_box_width - 1)
714
+ stdscr.addnstr(row, 2, f"{label:<4}", list_box_width - 3, curses.A_BOLD)
715
+ stdscr.addnstr(row, 7, f"{time_window:<11}", list_box_width - 8)
716
+ stdscr.addnstr(row, 19, title, list_box_width - 20)
717
+ if desc_width:
718
+ panel_top = list_bottom + 1
719
+ panel_bottom = min(height - 1, list_bottom + preview_height)
720
+ box_x = list_box_width + 1
721
+ for row in range(panel_top, panel_bottom + 1):
722
+ stdscr.addch(row, list_box_width, vline)
723
+ selected_desc = ""
724
+ desc_label = ""
725
+ if epg_lines:
726
+ desc_index = max(0, min(desc_index, len(epg_lines) - 1))
727
+ desc_label, _t, _title, selected_desc = epg_lines[desc_index]
728
+ if panel_top < height:
729
+ stdscr.addnstr(
730
+ panel_top,
731
+ box_x,
732
+ f"Description ({desc_label})",
733
+ desc_width - 1,
734
+ curses.A_BOLD,
735
+ )
736
+ if selected_desc:
737
+ desc_text = clip_text(selected_desc, max(10, desc_width * 5))
738
+ else:
739
+ desc_text = "No description."
740
+ desc_lines = text_wrap(desc_text, desc_width - 2)
741
+ max_lines = max(0, panel_bottom - panel_top - 1)
742
+ for i, line in enumerate(desc_lines[:max_lines]):
743
+ row = panel_top + 1 + i
744
+ if row >= height:
745
+ break
746
+ stdscr.addnstr(row, box_x, line, desc_width - 1)
747
+ key_row = list_bottom + 4 + min(5, len(epg_lines))
748
+ if key_row < height:
749
+ stdscr.addnstr(
750
+ key_row,
751
+ 0,
752
+ "Keys: Enter=play F=favorite f=favs c=cat m=mode s=sort r=refresh t=subs /=search \u2190/\u2192 details Esc=back q=quit",
753
+ list_width - 1,
754
+ )
755
+ else:
756
+ if list_bottom + 1 < height:
757
+ stdscr.addnstr(list_bottom + 1, 0, "No channels to display.", list_width - 1)
758
+
759
+ if show_help_panel:
760
+ panel_x = list_width + 1
761
+ for row in range(height):
762
+ stdscr.addch(row, list_width, vline)
763
+ help_lines = [
764
+ "Help",
765
+ "",
766
+ "/ search mode",
767
+ "Esc exit search",
768
+ "Enter play",
769
+ "F favorite",
770
+ "f favorites",
771
+ "c categories",
772
+ "m content mode",
773
+ "s sort",
774
+ "r refresh",
775
+ "t subtitles",
776
+ "Mouse wheel scroll",
777
+ "Click select",
778
+ "Double-click play",
779
+ "\u2190/\u2192 details",
780
+ "q quit",
781
+ "",
782
+ "Legend",
783
+ "* favorite",
784
+ ]
785
+ for i, line in enumerate(help_lines):
786
+ row = i
787
+ if row >= height:
788
+ break
789
+ if i == 0:
790
+ stdscr.addnstr(row, panel_x + 1, line, help_width - 2, curses.A_BOLD)
791
+ else:
792
+ stdscr.addnstr(row, panel_x + 1, line, help_width - 2)
793
+ stdscr.refresh()
794
+
795
+
796
+ def show_popup(stdscr: "curses._CursesWindow", title: str, message: str) -> None:
797
+ height, width = stdscr.getmaxyx()
798
+ lines = text_wrap(message, max(20, width - 8))
799
+ box_height = min(height - 4, max(5, len(lines) + 4))
800
+ box_width = min(width - 4, max(30, max(len(title) + 4, max((len(l) for l in lines), default=0) + 4)))
801
+ start_y = (height - box_height) // 2
802
+ start_x = (width - box_width) // 2
803
+ hline = getattr(curses, "ACS_HLINE", ord("-"))
804
+ vline = getattr(curses, "ACS_VLINE", ord("|"))
805
+ stdscr.attron(curses.A_BOLD)
806
+ stdscr.addch(start_y, start_x, getattr(curses, "ACS_ULCORNER", ord("+")))
807
+ stdscr.addch(start_y, start_x + box_width - 1, getattr(curses, "ACS_URCORNER", ord("+")))
808
+ stdscr.addch(start_y + box_height - 1, start_x, getattr(curses, "ACS_LLCORNER", ord("+")))
809
+ stdscr.addch(start_y + box_height - 1, start_x + box_width - 1, getattr(curses, "ACS_LRCORNER", ord("+")))
810
+ stdscr.attroff(curses.A_BOLD)
811
+ stdscr.hline(start_y, start_x + 1, hline, box_width - 2)
812
+ stdscr.hline(start_y + box_height - 1, start_x + 1, hline, box_width - 2)
813
+ for row in range(start_y + 1, start_y + box_height - 1):
814
+ stdscr.addch(row, start_x, vline)
815
+ stdscr.addch(row, start_x + box_width - 1, vline)
816
+ stdscr.addnstr(row, start_x + 1, " " * (box_width - 2), box_width - 2)
817
+ stdscr.addnstr(start_y + 1, start_x + 2, title, box_width - 4, curses.A_BOLD)
818
+ for i, line in enumerate(lines[: box_height - 4]):
819
+ stdscr.addnstr(start_y + 2 + i, start_x + 2, line, box_width - 4)
820
+ stdscr.addnstr(start_y + box_height - 2, start_x + 2, "Press any key...", box_width - 4)
821
+ stdscr.refresh()
822
+ stdscr.get_wch()
823
+
824
+
825
+ def render_splash(stdscr: "curses._CursesWindow", message: str, spinner_char: str) -> None:
826
+ stdscr.erase()
827
+ height, width = stdscr.getmaxyx()
828
+ logo = [
829
+ "$$$$$$$$\\ $$$$$$$$\\ $$\\ $$\\ $$$$$$\\ ",
830
+ "\\__$$ __| \\__$$ __|$$ | $$ |\\_$$ _|",
831
+ " $$ |$$\\ $$\\ $$ | $$ | $$ | $$ | ",
832
+ " $$ |\\$$\\ $$ |$$ | $$ | $$ | $$ | ",
833
+ " $$ | \\$$\\$$ / $$ | $$ | $$ | $$ | ",
834
+ " $$ | \\$$$ / $$ | $$ | $$ | $$ | ",
835
+ " $$ | \\$ / $$ | \\$$$$$$ |$$$$$$\\ ",
836
+ " \\__| \\_/ \\__| \\______/ \\______|",
837
+ ]
838
+ start_y = max(1, (height // 2) - (len(logo) // 2) - 2)
839
+ for i, line in enumerate(logo):
840
+ x = max(0, (width - len(line)) // 2)
841
+ stdscr.addnstr(start_y + i, x, line, width - 1, curses.A_BOLD)
842
+ version_line = f"tvTUI {VERSION}"
843
+ stdscr.addnstr(start_y + len(logo) + 1, max(0, (width - len(version_line)) // 2), version_line, width - 1)
844
+ msg = f"{message} {spinner_char}"
845
+ stdscr.addnstr(start_y + len(logo) + 3, max(0, (width - len(msg)) // 2), msg, width - 1)
846
+ stdscr.refresh()
847
+
848
+
849
+ def filter_channels(channels: List[Channel], query: str, programs: Dict[str, Program]) -> List[Channel]:
850
+ if not query:
851
+ return channels
852
+ query = query.lower()
853
+ filtered = []
854
+ for chan in channels:
855
+ program = format_program(programs, chan)
856
+ if query in chan.name.lower() or query in program.lower():
857
+ filtered.append(chan)
858
+ return filtered
859
+
860
+
861
+ def select_category(
862
+ stdscr: "curses._CursesWindow",
863
+ ) -> Optional[Tuple[str, str]]:
864
+ categories = [
865
+ ("All", "tv"),
866
+ ("Entertainment", "base"),
867
+ ("Live Events", "events"),
868
+ ]
869
+ index = 0
870
+ while True:
871
+ stdscr.erase()
872
+ height, width = stdscr.getmaxyx()
873
+ stdscr.addnstr(0, 0, "Select a category (Enter=choose, q=quit)", width - 1)
874
+ for i, (label, cat_id) in enumerate(categories):
875
+ line = f"{label} ({cat_id})"
876
+ if i == index:
877
+ stdscr.attron(curses.A_REVERSE)
878
+ stdscr.addnstr(2 + i, 2, line, width - 3)
879
+ stdscr.attroff(curses.A_REVERSE)
880
+ else:
881
+ stdscr.addnstr(2 + i, 2, line, width - 3)
882
+ stdscr.refresh()
883
+ ch = stdscr.get_wch()
884
+ if ch in ("q", "Q"):
885
+ return None
886
+ if ch in ("\n", "\r"):
887
+ return categories[index]
888
+ if ch in (curses.KEY_UP, "k"):
889
+ index = max(0, index - 1)
890
+ if ch in (curses.KEY_DOWN, "j"):
891
+ index = min(len(categories) - 1, index + 1)
892
+
893
+
894
+ def select_from_list(
895
+ stdscr: "curses._CursesWindow",
896
+ title: str,
897
+ items: List[Tuple[str, str]],
898
+ ) -> Optional[Tuple[str, str]]:
899
+ if not items:
900
+ return None
901
+ index = 0
902
+ while True:
903
+ stdscr.erase()
904
+ height, width = stdscr.getmaxyx()
905
+ stdscr.addnstr(0, 0, title, width - 1)
906
+ for i, (label, ident) in enumerate(items):
907
+ line = f"{label} ({ident})"
908
+ if i == index:
909
+ stdscr.attron(curses.A_REVERSE)
910
+ stdscr.addnstr(2 + i, 2, line, width - 3)
911
+ stdscr.attroff(curses.A_REVERSE)
912
+ else:
913
+ stdscr.addnstr(2 + i, 2, line, width - 3)
914
+ stdscr.refresh()
915
+ ch = stdscr.get_wch()
916
+ if ch in ("q", "Q", "\x1b"):
917
+ return None
918
+ if ch in ("\n", "\r"):
919
+ return items[index]
920
+ if ch in (curses.KEY_UP, "k"):
921
+ index = max(0, index - 1)
922
+ if ch in (curses.KEY_DOWN, "j"):
923
+ index = min(len(items) - 1, index + 1)
924
+
925
+
926
+ def run_tui(
927
+ initial_query: str,
928
+ favorites_only: bool,
929
+ categories_only: bool,
930
+ favorites_file: str,
931
+ history_file: str,
932
+ epg_cache: str,
933
+ channels_cache: str,
934
+ epg_url: str,
935
+ base_m3u_url: str,
936
+ streamed_base: str,
937
+ use_emoji_tags: bool,
938
+ show_help_panel: bool,
939
+ player_config: PlayerConfig,
940
+ subs_default: bool,
941
+ xtream_base_url: str,
942
+ xtream_username: str,
943
+ xtream_password: str,
944
+ xtream_use_for_tv: bool,
945
+ ) -> bool:
946
+ player = Player(player_config)
947
+ result = {"help_visible": show_help_panel}
948
+
949
+ def tui(stdscr: "curses._CursesWindow") -> None:
950
+ curses.curs_set(0)
951
+ if curses.has_colors():
952
+ curses.start_color()
953
+ curses.use_default_colors()
954
+ curses.init_pair(1, curses.COLOR_GREEN, -1)
955
+ curses.init_pair(2, curses.COLOR_CYAN, -1)
956
+ curses.init_pair(3, curses.COLOR_MAGENTA, -1)
957
+ curses.init_pair(4, curses.COLOR_YELLOW, -1)
958
+ curses.init_pair(5, curses.COLOR_BLUE, -1)
959
+ curses.init_pair(6, curses.COLOR_RED, -1)
960
+ curses.init_pair(7, curses.COLOR_WHITE, -1)
961
+ curses.init_pair(8, curses.COLOR_YELLOW, -1)
962
+ curses.mousemask(curses.ALL_MOUSE_EVENTS)
963
+ stdscr.keypad(True)
964
+ query = initial_query
965
+ help_visible = show_help_panel
966
+ search_mode = False
967
+ status_message = ""
968
+ subs_enabled = subs_default
969
+ sort_modes = ["default", "name", "category"]
970
+ sort_mode = "default"
971
+ content_modes = ["tv", "movie", "series"]
972
+ xtream_enabled = bool(xtream_base_url and xtream_username and xtream_password)
973
+ if not xtream_enabled:
974
+ content_modes = ["tv"]
975
+ content_mode = "tv"
976
+ spinner = ["|", "/", "-", "\\"]
977
+ filtered: List[Channel] = []
978
+ favorites_set: set = set()
979
+ selected_index = 0
980
+ top_index = 0
981
+ mode = "channels"
982
+
983
+ def splash_with_spinner(message: str, func):
984
+ result = {"value": None, "error": None}
985
+
986
+ def target():
987
+ try:
988
+ result["value"] = func()
989
+ except Exception as exc:
990
+ result["error"] = exc
991
+
992
+ thread = threading.Thread(target=target, daemon=True)
993
+ thread.start()
994
+ i = 0
995
+ while thread.is_alive():
996
+ render_splash(stdscr, message, spinner[i % len(spinner)])
997
+ time.sleep(0.1)
998
+ i += 1
999
+ thread.join()
1000
+ if result["error"]:
1001
+ raise result["error"]
1002
+ return result["value"]
1003
+
1004
+ def fetch_with_spinner(message: str, func):
1005
+ nonlocal status_message
1006
+ result = {"value": None, "error": None}
1007
+
1008
+ def target():
1009
+ try:
1010
+ result["value"] = func()
1011
+ except Exception as exc:
1012
+ result["error"] = exc
1013
+
1014
+ thread = threading.Thread(target=target, daemon=True)
1015
+ thread.start()
1016
+ i = 0
1017
+ while thread.is_alive():
1018
+ status_message = f"{message} {spinner[i % len(spinner)]}"
1019
+ render_screen(
1020
+ stdscr,
1021
+ filtered,
1022
+ programs,
1023
+ epg_index,
1024
+ favorites_set,
1025
+ selected_index,
1026
+ top_index,
1027
+ query,
1028
+ mode,
1029
+ help_visible,
1030
+ search_mode,
1031
+ use_emoji_tags,
1032
+ desc_index,
1033
+ status_message,
1034
+ sort_mode,
1035
+ content_mode,
1036
+ )
1037
+ time.sleep(0.1)
1038
+ i += 1
1039
+ thread.join()
1040
+ status_message = ""
1041
+ if result["error"]:
1042
+ raise result["error"]
1043
+ return result["value"]
1044
+ desc_index = 0
1045
+ last_selected_index = -1
1046
+ mode = "channels"
1047
+ if favorites_only:
1048
+ mode = "favorites"
1049
+ elif categories_only:
1050
+ mode = "categories"
1051
+
1052
+ def initial_load():
1053
+ ok, err = update_epg_cache(epg_cache, epg_url)
1054
+ programs = build_program_map(epg_cache)
1055
+ epg_index = build_epg_index(epg_cache)
1056
+ if content_mode == "tv" and xtream_enabled and xtream_use_for_tv:
1057
+ channels, chan_err = xtream_get_streams(
1058
+ xtream_base_url, xtream_username, xtream_password, "live"
1059
+ )
1060
+ else:
1061
+ channels, chan_err = get_iptv_channels(channels_cache, base_m3u_url)
1062
+ return ok, err, programs, epg_index, channels, chan_err
1063
+
1064
+ ok, err, programs, epg_index, channels, chan_err = splash_with_spinner(
1065
+ "Loading", initial_load
1066
+ )
1067
+ if not channels:
1068
+ channels = get_fallback_channels()
1069
+ if chan_err:
1070
+ show_popup(stdscr, "Network Error", chan_err)
1071
+ if not ok and err:
1072
+ show_popup(stdscr, "Network Error", err)
1073
+
1074
+ favorites = load_favorites(favorites_file)
1075
+ favorites_set = {f.url for f in favorites}
1076
+ if content_mode == "tv":
1077
+ if content_mode == "tv":
1078
+ channels = merge_with_favorites(channels, favorites)
1079
+ if categories_only:
1080
+ selection = select_category(stdscr)
1081
+ if selection:
1082
+ _, cat_id = selection
1083
+ channels, cat_err = get_category_channels(cat_id, streamed_base)
1084
+ if not channels and cat_err:
1085
+ show_popup(stdscr, "Network Error", cat_err)
1086
+ mode = "channels"
1087
+ filtered = apply_sort(
1088
+ filter_channels(channels, query, programs), sort_mode, use_emoji_tags
1089
+ )
1090
+ if favorites_only:
1091
+ filtered = apply_sort(filter_channels(favorites, query, programs), sort_mode, use_emoji_tags)
1092
+ mode = "favorites"
1093
+
1094
+ selected_index = 0
1095
+ top_index = 0
1096
+ desc_index = 0
1097
+ last_selected_index = 0
1098
+ while True:
1099
+ if selected_index >= len(filtered):
1100
+ selected_index = max(0, len(filtered) - 1)
1101
+ height, width = stdscr.getmaxyx()
1102
+ list_height = max(1, height - 2 - 7)
1103
+ if selected_index < top_index:
1104
+ top_index = selected_index
1105
+ if selected_index >= top_index + list_height:
1106
+ top_index = selected_index - list_height + 1
1107
+
1108
+ render_screen(
1109
+ stdscr,
1110
+ filtered,
1111
+ programs,
1112
+ epg_index,
1113
+ favorites_set,
1114
+ selected_index,
1115
+ top_index,
1116
+ query,
1117
+ mode,
1118
+ help_visible,
1119
+ search_mode,
1120
+ use_emoji_tags,
1121
+ desc_index,
1122
+ status_message,
1123
+ sort_mode,
1124
+ content_mode,
1125
+ )
1126
+ ch = stdscr.get_wch()
1127
+ if ch == curses.KEY_RESIZE:
1128
+ continue
1129
+ if ch == curses.KEY_MOUSE:
1130
+ try:
1131
+ _mid, mx, my, _mz, bstate = curses.getmouse()
1132
+ except curses.error:
1133
+ continue
1134
+ if bstate & curses.BUTTON4_PRESSED:
1135
+ selected_index = max(0, selected_index - 1)
1136
+ elif bstate & curses.BUTTON5_PRESSED:
1137
+ selected_index = min(len(filtered) - 1, selected_index + 1)
1138
+ else:
1139
+ list_top = 1
1140
+ list_bottom = list_top + list_height
1141
+ if list_top <= my < list_bottom:
1142
+ new_index = top_index + (my - list_top)
1143
+ if 0 <= new_index < len(filtered):
1144
+ selected_index = new_index
1145
+ if bstate & getattr(curses, "BUTTON1_DOUBLE_CLICKED", 0):
1146
+ channel = filtered[selected_index]
1147
+ if content_mode == "series":
1148
+ episodes, ep_err = xtream_get_series_episodes(
1149
+ xtream_base_url,
1150
+ xtream_username,
1151
+ xtream_password,
1152
+ channel.tvg_id,
1153
+ )
1154
+ if not episodes and ep_err:
1155
+ show_popup(stdscr, "Network Error", ep_err)
1156
+ continue
1157
+ selection = select_from_list(
1158
+ stdscr,
1159
+ "Select Episode (Enter=play, q=cancel)",
1160
+ episodes,
1161
+ )
1162
+ if selection:
1163
+ ep_label, ep_url = selection
1164
+ append_history(
1165
+ history_file, Channel(ep_label, ep_url, "")
1166
+ )
1167
+ player.play(ep_url, subs_enabled)
1168
+ else:
1169
+ append_history(history_file, channel)
1170
+ player.play(channel.url, subs_enabled)
1171
+ if selected_index != last_selected_index:
1172
+ desc_index = 0
1173
+ last_selected_index = selected_index
1174
+ continue
1175
+ if search_mode:
1176
+ if ch in ("\n", "\r", "\x1b"):
1177
+ search_mode = False
1178
+ continue
1179
+ if ch in (curses.KEY_BACKSPACE, "\b", "\x7f"):
1180
+ query = query[:-1]
1181
+ filtered = apply_sort(
1182
+ filter_channels(channels, query, programs), sort_mode, use_emoji_tags
1183
+ )
1184
+ if mode == "favorites":
1185
+ filtered = apply_sort(
1186
+ filter_channels(favorites, query, programs),
1187
+ sort_mode,
1188
+ use_emoji_tags,
1189
+ )
1190
+ selected_index = 0
1191
+ top_index = 0
1192
+ desc_index = 0
1193
+ last_selected_index = 0
1194
+ continue
1195
+ if isinstance(ch, str) and ch.isprintable():
1196
+ query += ch
1197
+ filtered = apply_sort(
1198
+ filter_channels(channels, query, programs), sort_mode, use_emoji_tags
1199
+ )
1200
+ if mode == "favorites":
1201
+ filtered = apply_sort(
1202
+ filter_channels(favorites, query, programs),
1203
+ sort_mode,
1204
+ use_emoji_tags,
1205
+ )
1206
+ selected_index = 0
1207
+ top_index = 0
1208
+ desc_index = 0
1209
+ last_selected_index = 0
1210
+ continue
1211
+ if ch in ("q", "Q"):
1212
+ result["help_visible"] = help_visible
1213
+ return
1214
+ if ch in ("\n", "\r"):
1215
+ if filtered:
1216
+ channel = filtered[selected_index]
1217
+ if content_mode == "series":
1218
+ episodes, ep_err = fetch_with_spinner(
1219
+ "Loading episodes",
1220
+ lambda: xtream_get_series_episodes(
1221
+ xtream_base_url,
1222
+ xtream_username,
1223
+ xtream_password,
1224
+ channel.tvg_id,
1225
+ ),
1226
+ )
1227
+ if not episodes and ep_err:
1228
+ show_popup(stdscr, "Network Error", ep_err)
1229
+ continue
1230
+ selection = select_from_list(
1231
+ stdscr, "Select Episode (Enter=play, q=cancel)", episodes
1232
+ )
1233
+ if selection:
1234
+ ep_label, ep_url = selection
1235
+ append_history(history_file, Channel(ep_label, ep_url, ""))
1236
+ player.play(ep_url, subs_enabled)
1237
+ else:
1238
+ append_history(history_file, channel)
1239
+ player.play(channel.url, subs_enabled)
1240
+ continue
1241
+ if ch in ("h", "H"):
1242
+ help_visible = not help_visible
1243
+ continue
1244
+ if ch in ("t", "T"):
1245
+ subs_enabled = not subs_enabled
1246
+ status_message = f"Subtitles: {'On' if subs_enabled else 'Off'}"
1247
+ continue
1248
+ if ch in ("f",):
1249
+ if mode == "favorites":
1250
+ mode = "channels"
1251
+ else:
1252
+ mode = "favorites"
1253
+ filtered = apply_sort(
1254
+ filter_channels(channels, query, programs), sort_mode, use_emoji_tags
1255
+ )
1256
+ if mode == "favorites":
1257
+ filtered = apply_sort(
1258
+ filter_channels(favorites, query, programs),
1259
+ sort_mode,
1260
+ use_emoji_tags,
1261
+ )
1262
+ selected_index = 0
1263
+ top_index = 0
1264
+ desc_index = 0
1265
+ last_selected_index = 0
1266
+ continue
1267
+ if ch in ("F",):
1268
+ if filtered:
1269
+ channel = filtered[selected_index]
1270
+ if content_mode != "series":
1271
+ toggle_favorite(favorites_file, channel)
1272
+ favorites = load_favorites(favorites_file)
1273
+ favorites_set = {f.url for f in favorites}
1274
+ if content_mode == "tv":
1275
+ channels = merge_with_favorites(channels, favorites)
1276
+ if mode == "favorites":
1277
+ filtered = apply_sort(
1278
+ filter_channels(favorites, query, programs),
1279
+ sort_mode,
1280
+ use_emoji_tags,
1281
+ )
1282
+ selected_index = 0
1283
+ top_index = 0
1284
+ desc_index = 0
1285
+ last_selected_index = 0
1286
+ continue
1287
+ if ch in ("c", "C"):
1288
+ if content_mode == "tv" and (not xtream_enabled or not xtream_use_for_tv):
1289
+ selection = select_category(stdscr)
1290
+ if selection:
1291
+ _, cat_id = selection
1292
+ channels, cat_err = get_category_channels(cat_id, streamed_base)
1293
+ programs = build_program_map(epg_cache)
1294
+ epg_index = build_epg_index(epg_cache)
1295
+ query = ""
1296
+ mode = "channels"
1297
+ if content_mode == "tv":
1298
+ channels = merge_with_favorites(channels, favorites)
1299
+ filtered = apply_sort(
1300
+ filter_channels(channels, query, programs),
1301
+ sort_mode,
1302
+ use_emoji_tags,
1303
+ )
1304
+ selected_index = 0
1305
+ top_index = 0
1306
+ desc_index = 0
1307
+ last_selected_index = 0
1308
+ if not channels and cat_err:
1309
+ show_popup(stdscr, "Network Error", cat_err)
1310
+ else:
1311
+ if not xtream_enabled:
1312
+ show_popup(stdscr, "Xtream Not Configured", "Add Xtream credentials in config.")
1313
+ continue
1314
+ cat_items, cat_err = fetch_with_spinner(
1315
+ "Loading categories",
1316
+ lambda: xtream_get_categories(
1317
+ xtream_base_url, xtream_username, xtream_password, content_mode
1318
+ ),
1319
+ )
1320
+ if cat_err:
1321
+ show_popup(stdscr, "Network Error", cat_err)
1322
+ continue
1323
+ selection = select_from_list(
1324
+ stdscr, "Select Category (Enter=browse, q=cancel)", cat_items
1325
+ )
1326
+ if selection:
1327
+ _, cat_id = selection
1328
+ channels, cat_err = fetch_with_spinner(
1329
+ "Loading streams",
1330
+ lambda: xtream_get_streams(
1331
+ xtream_base_url,
1332
+ xtream_username,
1333
+ xtream_password,
1334
+ content_mode,
1335
+ category_id=cat_id,
1336
+ ),
1337
+ )
1338
+ if cat_err:
1339
+ show_popup(stdscr, "Network Error", cat_err)
1340
+ query = ""
1341
+ mode = "channels"
1342
+ filtered = apply_sort(
1343
+ filter_channels(channels, query, programs),
1344
+ sort_mode,
1345
+ use_emoji_tags,
1346
+ )
1347
+ selected_index = 0
1348
+ top_index = 0
1349
+ desc_index = 0
1350
+ last_selected_index = 0
1351
+ continue
1352
+ if ch in ("m", "M"):
1353
+ if not xtream_enabled:
1354
+ show_popup(stdscr, "Xtream Not Configured", "Add Xtream credentials in config.")
1355
+ continue
1356
+ current = content_modes.index(content_mode)
1357
+ content_mode = content_modes[(current + 1) % len(content_modes)]
1358
+ status_message = f"Content: {content_mode}"
1359
+ if content_mode == "tv" and xtream_use_for_tv:
1360
+ channels, chan_err = fetch_with_spinner(
1361
+ "Loading Xtream TV",
1362
+ lambda: xtream_get_streams(
1363
+ xtream_base_url, xtream_username, xtream_password, "live"
1364
+ ),
1365
+ )
1366
+ elif content_mode == "tv":
1367
+ channels, chan_err = get_iptv_channels(channels_cache, base_m3u_url)
1368
+ else:
1369
+ channels, chan_err = fetch_with_spinner(
1370
+ "Loading streams",
1371
+ lambda: xtream_get_streams(
1372
+ xtream_base_url, xtream_username, xtream_password, content_mode
1373
+ ),
1374
+ )
1375
+ if chan_err:
1376
+ show_popup(stdscr, "Network Error", chan_err)
1377
+ query = ""
1378
+ mode = "channels"
1379
+ filtered = apply_sort(
1380
+ filter_channels(channels, query, programs), sort_mode, use_emoji_tags
1381
+ )
1382
+ selected_index = 0
1383
+ top_index = 0
1384
+ desc_index = 0
1385
+ last_selected_index = 0
1386
+ continue
1387
+ if ch in ("s", "S"):
1388
+ current = sort_modes.index(sort_mode)
1389
+ sort_mode = sort_modes[(current + 1) % len(sort_modes)]
1390
+ status_message = f"Sort: {sort_mode}"
1391
+ filtered = apply_sort(filtered, sort_mode, use_emoji_tags)
1392
+ selected_index = 0
1393
+ top_index = 0
1394
+ desc_index = 0
1395
+ last_selected_index = 0
1396
+ continue
1397
+ if ch in ("r", "R"):
1398
+ status_message = "Refreshing..."
1399
+ render_screen(
1400
+ stdscr,
1401
+ filtered,
1402
+ programs,
1403
+ epg_index,
1404
+ favorites_set,
1405
+ selected_index,
1406
+ top_index,
1407
+ query,
1408
+ mode,
1409
+ help_visible,
1410
+ search_mode,
1411
+ use_emoji_tags,
1412
+ desc_index,
1413
+ status_message,
1414
+ sort_mode,
1415
+ content_mode,
1416
+ )
1417
+ ok, err = update_epg_cache(epg_cache, epg_url)
1418
+ programs = build_program_map(epg_cache)
1419
+ epg_index = build_epg_index(epg_cache)
1420
+ if content_mode == "tv" and xtream_enabled and xtream_use_for_tv:
1421
+ channels, chan_err = fetch_with_spinner(
1422
+ "Loading Xtream TV",
1423
+ lambda: xtream_get_streams(
1424
+ xtream_base_url, xtream_username, xtream_password, "live"
1425
+ ),
1426
+ )
1427
+ elif content_mode == "tv":
1428
+ channels, chan_err = get_iptv_channels(channels_cache, base_m3u_url)
1429
+ else:
1430
+ channels, chan_err = fetch_with_spinner(
1431
+ "Loading streams",
1432
+ lambda: xtream_get_streams(
1433
+ xtream_base_url, xtream_username, xtream_password, content_mode
1434
+ ),
1435
+ )
1436
+ if not channels:
1437
+ channels = get_fallback_channels()
1438
+ if content_mode == "tv":
1439
+ channels = merge_with_favorites(channels, favorites)
1440
+ if mode == "favorites":
1441
+ filtered = apply_sort(
1442
+ filter_channels(favorites, query, programs),
1443
+ sort_mode,
1444
+ use_emoji_tags,
1445
+ )
1446
+ else:
1447
+ filtered = apply_sort(
1448
+ filter_channels(channels, query, programs),
1449
+ sort_mode,
1450
+ use_emoji_tags,
1451
+ )
1452
+ selected_index = 0
1453
+ top_index = 0
1454
+ desc_index = 0
1455
+ last_selected_index = 0
1456
+ status_message = ""
1457
+ if not ok and err:
1458
+ show_popup(stdscr, "Network Error", err)
1459
+ elif chan_err:
1460
+ show_popup(stdscr, "Network Error", chan_err)
1461
+ continue
1462
+ if ch in (curses.KEY_UP, "k"):
1463
+ selected_index = max(0, selected_index - 1)
1464
+ if selected_index != last_selected_index:
1465
+ desc_index = 0
1466
+ last_selected_index = selected_index
1467
+ continue
1468
+ if ch in (curses.KEY_DOWN, "j"):
1469
+ selected_index = min(len(filtered) - 1, selected_index + 1)
1470
+ if selected_index != last_selected_index:
1471
+ desc_index = 0
1472
+ last_selected_index = selected_index
1473
+ continue
1474
+ if ch in (curses.KEY_PPAGE,):
1475
+ selected_index = max(0, selected_index - 10)
1476
+ if selected_index != last_selected_index:
1477
+ desc_index = 0
1478
+ last_selected_index = selected_index
1479
+ continue
1480
+ if ch in (curses.KEY_NPAGE,):
1481
+ selected_index = min(len(filtered) - 1, selected_index + 10)
1482
+ if selected_index != last_selected_index:
1483
+ desc_index = 0
1484
+ last_selected_index = selected_index
1485
+ continue
1486
+ if ch in (curses.KEY_RIGHT,):
1487
+ if filtered:
1488
+ items = program_listing(epg_index, filtered[selected_index])
1489
+ if items:
1490
+ desc_index = min(len(items) - 1, desc_index + 1)
1491
+ continue
1492
+ if ch in (curses.KEY_LEFT,):
1493
+ if filtered:
1494
+ items = program_listing(epg_index, filtered[selected_index])
1495
+ if items:
1496
+ desc_index = max(0, desc_index - 1)
1497
+ continue
1498
+ if ch in (curses.KEY_BACKSPACE, "\b", "\x7f"):
1499
+ continue
1500
+ if ch == "/":
1501
+ if search_mode:
1502
+ query = ""
1503
+ search_mode = True
1504
+ continue
1505
+
1506
+ curses.wrapper(tui)
1507
+ return result["help_visible"]
1508
+
1509
+
1510
+ def parse_args(argv: List[str]) -> argparse.Namespace:
1511
+ parser = argparse.ArgumentParser(
1512
+ prog="tvtui",
1513
+ description="tvTUI - IPTV terminal user interface",
1514
+ )
1515
+ parser.add_argument("query", nargs="?", default="")
1516
+ parser.add_argument("-v", "--version", action="store_true", help="show version")
1517
+ parser.add_argument("--clear-cache", action="store_true", help="clear cached data")
1518
+ parser.add_argument("-f", "--favorites", action="store_true", help="show favorites only")
1519
+ parser.add_argument("-c", "--categories", action="store_true", help="browse categories")
1520
+ parser.add_argument(
1521
+ "--config",
1522
+ default="~/.config/tvtui/config.json",
1523
+ help="path to config file",
1524
+ )
1525
+ parser.add_argument("--epg-url", help="override EPG XML URL")
1526
+ parser.add_argument("--source-url", help="override IPTV M3U URL")
1527
+ parser.add_argument("--streamed-base", help="override categories base URL")
1528
+ parser.add_argument("--emoji-tags", action="store_true", help="use emoji category tags")
1529
+ parser.add_argument("--no-emoji-tags", action="store_true", help="disable emoji category tags")
1530
+ return parser.parse_args(argv)
1531
+
1532
+
1533
+ def clear_cache(cache_dir: str, epg_cache: str) -> None:
1534
+ if os.path.isdir(cache_dir):
1535
+ shutil.rmtree(cache_dir, ignore_errors=True)
1536
+ if os.path.exists(epg_cache):
1537
+ os.remove(epg_cache)
1538
+ print("Cache cleared.")
1539
+
1540
+
1541
+ def load_config(path: str) -> Dict[str, str]:
1542
+ expanded = os.path.expanduser(path)
1543
+ if not os.path.exists(expanded):
1544
+ return {}
1545
+ try:
1546
+ with open(expanded, "r", encoding="utf-8") as f:
1547
+ data = json.load(f)
1548
+ if isinstance(data, dict):
1549
+ return {str(k): str(v) for k, v in data.items()}
1550
+ except (OSError, json.JSONDecodeError):
1551
+ return {}
1552
+ return {}
1553
+
1554
+
1555
+ def save_config(path: str, updates: Dict[str, object]) -> None:
1556
+ expanded = os.path.expanduser(path)
1557
+ base = os.path.dirname(expanded)
1558
+ if base:
1559
+ os.makedirs(base, exist_ok=True)
1560
+ data = load_config(path)
1561
+ for key, value in updates.items():
1562
+ data[key] = value
1563
+ tmp = expanded + ".tmp"
1564
+ with open(tmp, "w", encoding="utf-8") as f:
1565
+ json.dump(data, f, indent=2, sort_keys=True)
1566
+ os.replace(tmp, expanded)
1567
+
1568
+
1569
+ def parse_bool(value: object) -> Optional[bool]:
1570
+ if isinstance(value, bool):
1571
+ return value
1572
+ if isinstance(value, str):
1573
+ normalized = value.strip().lower()
1574
+ if normalized in ("1", "true", "yes", "on"):
1575
+ return True
1576
+ if normalized in ("0", "false", "no", "off"):
1577
+ return False
1578
+ return None
1579
+
1580
+
1581
+ def normalize_args(value: object) -> List[str]:
1582
+ if value is None:
1583
+ return []
1584
+ if isinstance(value, list):
1585
+ return [str(v) for v in value]
1586
+ if isinstance(value, str):
1587
+ return shlex.split(value)
1588
+ return [str(value)]
1589
+
1590
+
1591
+ def main(argv: Optional[List[str]] = None) -> int:
1592
+ args = parse_args(argv if argv is not None else sys.argv[1:])
1593
+ if args.version:
1594
+ print(f"tvTUI {VERSION}")
1595
+ return 0
1596
+
1597
+ config_dir, cache_dir, favorites_file, history_file, epg_cache, channels_cache = config_paths()
1598
+ ensure_dirs(config_dir, cache_dir)
1599
+
1600
+ if args.clear_cache:
1601
+ clear_cache(cache_dir, epg_cache)
1602
+ return 0
1603
+
1604
+ config = load_config(args.config)
1605
+ epg_url = config.get("epg_url", DEFAULT_EPG_URL)
1606
+ base_m3u_url = config.get("source_url", DEFAULT_BASE_M3U_URL)
1607
+ streamed_base = config.get("streamed_base", DEFAULT_STREAMED_BASE)
1608
+ show_help_panel = True
1609
+ config_help = parse_bool(config.get("show_help_panel"))
1610
+ if config_help is not None:
1611
+ show_help_panel = config_help
1612
+ use_emoji_tags = False
1613
+ config_emoji = parse_bool(config.get("use_emoji_tags"))
1614
+ if config_emoji is not None:
1615
+ use_emoji_tags = config_emoji
1616
+ subs_default = False
1617
+ config_subs = parse_bool(config.get("subs_enabled_default"))
1618
+ if config_subs is not None:
1619
+ subs_default = config_subs
1620
+ player_name = str(config.get("player", "auto")).lower()
1621
+ player_args = normalize_args(config.get("player_args"))
1622
+ custom_command = normalize_args(config.get("custom_command"))
1623
+ custom_subs_on_args = normalize_args(config.get("custom_subs_on_args"))
1624
+ custom_subs_off_args = normalize_args(config.get("custom_subs_off_args"))
1625
+ mpv_subs_on_args = normalize_args(config.get("mpv_subs_on_args"))
1626
+ mpv_subs_off_args = normalize_args(config.get("mpv_subs_off_args"))
1627
+ vlc_sub_track = 1
1628
+ try:
1629
+ vlc_sub_track = int(config.get("vlc_sub_track", 1))
1630
+ except (TypeError, ValueError):
1631
+ vlc_sub_track = 1
1632
+ if not mpv_subs_on_args:
1633
+ mpv_subs_on_args = ["--sub-visibility=yes", "--sid=auto"]
1634
+ if not mpv_subs_off_args:
1635
+ mpv_subs_off_args = ["--sub-visibility=no", "--sid=no"]
1636
+ player_config = PlayerConfig(
1637
+ name=player_name,
1638
+ args=player_args,
1639
+ custom_command=custom_command,
1640
+ subs_on_args=custom_subs_on_args if player_name == "custom" else mpv_subs_on_args,
1641
+ subs_off_args=custom_subs_off_args if player_name == "custom" else mpv_subs_off_args,
1642
+ vlc_sub_track=vlc_sub_track,
1643
+ )
1644
+ xtream_base_url = str(config.get("xtream_base_url", "")).strip()
1645
+ xtream_username = str(config.get("xtream_username", "")).strip()
1646
+ xtream_password = str(config.get("xtream_password", "")).strip()
1647
+ xtream_use_for_tv = True
1648
+ xtream_tv = parse_bool(config.get("xtream_use_for_tv"))
1649
+ if xtream_tv is not None:
1650
+ xtream_use_for_tv = xtream_tv
1651
+ if args.epg_url:
1652
+ epg_url = args.epg_url
1653
+ if args.source_url:
1654
+ base_m3u_url = args.source_url
1655
+ if args.streamed_base:
1656
+ streamed_base = args.streamed_base
1657
+ if args.emoji_tags:
1658
+ use_emoji_tags = True
1659
+ if args.no_emoji_tags:
1660
+ use_emoji_tags = False
1661
+
1662
+ show_help_panel = run_tui(
1663
+ initial_query=args.query,
1664
+ favorites_only=args.favorites,
1665
+ categories_only=args.categories,
1666
+ favorites_file=favorites_file,
1667
+ history_file=history_file,
1668
+ epg_cache=epg_cache,
1669
+ channels_cache=channels_cache,
1670
+ epg_url=epg_url,
1671
+ base_m3u_url=base_m3u_url,
1672
+ streamed_base=streamed_base,
1673
+ use_emoji_tags=use_emoji_tags,
1674
+ show_help_panel=show_help_panel,
1675
+ player_config=player_config,
1676
+ subs_default=subs_default,
1677
+ xtream_base_url=xtream_base_url,
1678
+ xtream_username=xtream_username,
1679
+ xtream_password=xtream_password,
1680
+ xtream_use_for_tv=xtream_use_for_tv,
1681
+ )
1682
+ save_config(args.config, {"show_help_panel": show_help_panel})
1683
+ return 0
1684
+
1685
+
1686
+ if __name__ == "__main__":
1687
+ raise SystemExit(main(sys.argv[1:]))