iptv player
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

404 lines
16 KiB

#!/usr/bin/env python3
import os
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
from urllib.request import urlopen, Request
import urwid
from urllib.error import URLError
# Configuration
M3U_URL = "http://10.0.0.17:8409/iptv/channels.m3u"
XMLTV_URL = "http://10.0.0.17:8409/iptv/xmltv.xml"
MPV_COMMAND = ["mpv", "--really-quiet", "--no-terminal", "--force-window=immediate"]
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
# Color Palette
PALETTE = [
('header', 'white', 'dark blue'),
('footer', 'white', 'dark blue'),
('channel', 'black', 'light gray'),
('channel_focus', 'white', 'dark blue'),
('program_now', 'white', 'dark green'),
('program_next', 'black', 'light gray'),
('error', 'white', 'dark red'),
('divider', 'light gray', ''),
('program_desc', 'light cyan', ''), # New color for descriptions
('time', 'yellow', ''), # New color for time displays
('title', 'bold', ''), # New color for titles
]
# ASCII Art
BANNER = r"""
__________._____. ___. .__ __ _______________ ____
\______ \__\_ |__\_ |__ |__|/ |_ \__ ___/\ \ / /
| | _/ || __ \| __ \| \ __\ | | \ Y /
| | \ || \_\ \ \_\ \ || | | | \ /
|______ /__||___ /___ /__||__| |____| \___/
\/ \/ \/
"""
class ChannelButton(urwid.Button):
def __init__(self, channel, *args, **kwargs):
self.channel = channel
if channel.get('current_show'):
label = f"{channel['name']}\nNow: {channel['current_show']['title']}"
if channel.get('next_show'):
label += f"\nNext: {channel['next_show']['title']}"
else:
label = channel['name']
super().__init__(label, *args, **kwargs)
class FocusAwareListBox(urwid.ListBox):
"""ListBox that tracks focus changes"""
def __init__(self, body, on_focus_change=None):
super().__init__(body)
self.on_focus_change = on_focus_change
self._last_focus = None
def change_focus(self, size, position, *args, **kwargs):
super().change_focus(size, position, *args, **kwargs)
current_focus = self.focus
if current_focus != self._last_focus and self.on_focus_change:
self.on_focus_change(current_focus)
self._last_focus = current_focus
class IPTVPlayer:
def __init__(self):
self.channels = []
self.programs = {}
self.current_channel = None
self.mpv_process = None
self.load_data()
self.setup_ui()
def load_data(self):
print("\n[DEBUG] Loading IPTV data...")
# Load M3U playlist
try:
req = Request(M3U_URL, headers={'User-Agent': USER_AGENT})
with urlopen(req, timeout=10) as response:
m3u_data = response.read().decode('utf-8')
print(f"[DEBUG] Received M3U data (length: {len(m3u_data)} bytes)")
# Parse M3U
lines = m3u_data.split('\n')
current_channel = None
for line in lines:
line = line.strip()
if line.startswith('#EXTINF:'):
# Parse channel info
tvg_id = re.search(r'tvg-id="([^"]*)"', line)
tvg_name = re.search(r'tvg-name="([^"]*)"', line)
tvg_logo = re.search(r'tvg-logo="([^"]*)"', line)
group_title = re.search(r'group-title="([^"]*)"', line)
channel_title = line.split(',')[-1].strip()
current_channel = {
'id': tvg_id.group(1) if tvg_id else "",
'name': tvg_name.group(1) if tvg_name else channel_title,
'logo': tvg_logo.group(1) if tvg_logo else "",
'group': group_title.group(1) if group_title else "Other",
'title': channel_title,
'url': None,
'current_show': None,
'next_show': None
}
elif line.startswith('http://'):
if current_channel:
current_channel['url'] = line
self.channels.append(current_channel)
current_channel = None
if not self.channels:
print("[WARNING] No channels found in M3U file!")
self.add_error_channel("No channels found in playlist")
else:
print(f"[DEBUG] Successfully loaded {len(self.channels)} channels")
except Exception as e:
print(f"[ERROR] Failed to load M3U: {str(e)}")
self.add_error_channel(f"Error loading playlist: {str(e)}")
# Load XMLTV guide
if self.channels and not self.channels[0]['name'].startswith("Error:"):
try:
req = Request(XMLTV_URL, headers={'User-Agent': USER_AGENT})
with urlopen(req, timeout=10) as response:
xml_data = response.read().decode('utf-8')
print(f"[DEBUG] Received XMLTV data (length: {len(xml_data)} bytes)")
root = ET.fromstring(xml_data)
for programme in root.findall('programme'):
channel_id = programme.get('channel')
start = self.parse_xmltv_time(programme.get('start'))
stop = self.parse_xmltv_time(programme.get('stop'))
title_elem = programme.find('title')
desc_elem = programme.find('desc')
if channel_id not in self.programs:
self.programs[channel_id] = []
self.programs[channel_id].append({
'start': start,
'stop': stop,
'title': title_elem.text if title_elem is not None else "No Title",
'desc': desc_elem.text if desc_elem is not None else ""
})
print(f"[DEBUG] Loaded EPG data for {len(self.programs)} channels")
# Pre-load current and next shows for each channel
now = datetime.now()
for channel in self.channels:
if channel['id'] in self.programs:
shows = self.programs[channel['id']]
for i, show in enumerate(shows):
if show['start'] <= now < show['stop']:
channel['current_show'] = show
if i+1 < len(shows):
channel['next_show'] = shows[i+1]
break
elif show['start'] > now:
channel['next_show'] = show
break
except Exception as e:
print(f"[WARNING] Failed to load EPG: {str(e)}")
def add_error_channel(self, message):
self.channels.append({
'id': "error_channel",
'name': f"Error: {message}",
'logo': "",
'group': "Error",
'url': "",
'title': "Check your playlist URL",
'current_show': None,
'next_show': None
})
def parse_xmltv_time(self, time_str):
try:
return datetime.strptime(time_str[:14], "%Y%m%d%H%M%S")
except:
return datetime.now()
def setup_ui(self):
# Create channel list with current show info
channel_items = []
for channel in self.channels:
# Create custom button that knows its channel
btn = ChannelButton(channel)
# Connect the signal with the correct channel using a closure
def make_click_handler(c):
return lambda button: self.on_channel_select(c)
urwid.connect_signal(btn, 'click', make_click_handler(channel))
# Apply different colors based on channel status
if channel.get('url'):
channel_item = urwid.AttrMap(btn, 'channel', 'channel_focus')
else:
channel_item = urwid.AttrMap(btn, 'error', 'error')
channel_items.append(channel_item)
# Create list box that tracks focus changes
self.channel_list = FocusAwareListBox(
urwid.SimpleFocusListWalker(channel_items),
on_focus_change=self.on_focus_change
)
# Create program guide with scrollable content
self.program_walker = urwid.SimpleFocusListWalker([])
self.program_listbox = urwid.ListBox(self.program_walker)
program_frame = urwid.Frame(
urwid.LineBox(self.program_listbox, title="Program Details"),
footer=urwid.AttrMap(urwid.Text("Q: Quit | ↑↓: Navigate | Enter: Play Channel | L: Reload"), 'footer')
)
# Create header with ASCII art
header_text = BANNER + "\nBibbitTV Terminal Player"
header = urwid.AttrMap(urwid.Text(header_text, align='center'), 'header')
# Create columns
columns = urwid.Columns([
('weight', 1, urwid.LineBox(self.channel_list, title="Channels")),
('weight', 2, program_frame)
], dividechars=1)
# Main layout with header
layout = urwid.Pile([
('pack', header),
('pack', urwid.Divider()),
columns
])
# Overlay for centering
self.top = urwid.Overlay(
urwid.LineBox(layout, title=""),
urwid.SolidFill(' '),
align='center',
width=('relative', 85),
valign='middle',
height=('relative', 85)
)
def on_focus_change(self, focused_widget):
"""Update program info when focusing a channel"""
if focused_widget:
# Get the button inside the AttrMap
button = focused_widget.base_widget
if hasattr(button, 'channel'):
self.on_channel_hover(button.channel)
def on_channel_hover(self, channel):
"""Update program info"""
self.current_channel = channel
program_info = self.get_program_info(channel)
self.program_walker[:] = [urwid.Text(line) for line in program_info]
def on_channel_select(self, channel):
"""Play channel when selected"""
if channel.get('url'):
self.play_channel(channel['url'])
def get_program_info(self, channel):
info = []
info.append([("header", f"📺 Channel: {channel['name']}")])
if channel.get('group'):
info.append([("title", f"Group: {channel['group']}")])
if channel.get('current_show'):
now = datetime.now()
remaining = (channel['current_show']['stop'] - now).seconds // 60
start_time = channel['current_show']['start'].strftime("%H:%M")
end_time = channel['current_show']['stop'].strftime("%H:%M")
# Current show section with colorful formatting
info.append([]) # Empty line
info.append([("program_now", "⏺ NOW PLAYING")])
info.append([("title", f"Title: {channel['current_show']['title']}")])
info.append([
("time", f"Time: {start_time} - {end_time} "),
("", f"({remaining} minutes remaining)")
])
if channel['current_show']['desc']:
info.append([]) # Empty line
info.append([("program_desc", "📝 Description:")])
# Split long description into multiple lines
desc = channel['current_show']['desc']
max_line_length = 60
for i in range(0, len(desc), max_line_length):
info.append([('program_desc', desc[i:i+max_line_length])])
# Next show section
if channel.get('next_show'):
next_start = channel['next_show']['start'].strftime("%H:%M")
next_end = channel['next_show']['stop'].strftime("%H:%M")
info.append([]) # Empty line
info.append([("divider", "" * 50)])
info.append([]) # Empty line
info.append([("program_next", "⏭ UP NEXT")])
info.append([("title", f"Title: {channel['next_show']['title']}")])
info.append([("time", f"Time: {next_start} - {next_end}")])
if channel['next_show']['desc']:
info.append([]) # Empty line
info.append([("program_desc", "📝 Description:")])
desc = channel['next_show']['desc']
# Limit description to 5 lines to prevent overflow
max_lines = 5
line_count = 0
for i in range(0, len(desc), max_line_length):
if line_count >= max_lines:
info.append([('program_desc', '... (description truncated)')])
break
info.append([('program_desc', desc[i:i+max_line_length])])
line_count += 1
else:
info.append([("", "\nNo current program information available")])
return info
def play_channel(self, url):
if self.mpv_process:
self.mpv_process.terminate()
self.mpv_process.wait()
try:
self.mpv_process = subprocess.Popen(MPV_COMMAND + [url])
except Exception as e:
self.program_walker[:] = [urwid.Text(("error", f"Failed to play stream:\n{str(e)}"))]
def reload_data(self):
self.channels = []
self.programs = {}
self.load_data()
self.setup_ui()
def run(self):
# Enable mouse support and cursor visibility
screen = urwid.raw_display.Screen()
screen.set_terminal_properties(colors=256)
screen.set_mouse_tracking()
loop = urwid.MainLoop(
self.top,
palette=PALETTE,
screen=screen,
unhandled_input=self.handle_input,
handle_mouse=True
)
loop.run()
def handle_input(self, key):
if key in ('q', 'Q'):
self.quit_player()
elif key in ('l', 'L'):
self.reload_data()
elif key == 'enter':
if self.current_channel and self.current_channel.get('url'):
self.play_channel(self.current_channel['url'])
def quit_player(self):
if self.mpv_process:
self.mpv_process.terminate()
self.mpv_process.wait()
raise urwid.ExitMainLoop()
def __del__(self):
if hasattr(self, 'mpv_process') and self.mpv_process:
self.mpv_process.terminate()
def check_mpv_installed():
try:
if os.name == 'nt':
subprocess.run(["where.exe", "mpv"], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
else:
subprocess.run(["which", "mpv"], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return True
except subprocess.CalledProcessError:
return False
def main():
if not check_mpv_installed():
print("Error: mpv player is required but not found. Please install mpv first.")
print("You can download it from: https://mpv.io/installation/")
sys.exit(1)
player = IPTVPlayer()
player.run()
if __name__ == "__main__":
main()