ipmpv/main.py

830 lines
24 KiB
Python
Executable File

#!/usr/bin/python
import sys
import mpv
import requests
import flask
import re
import subprocess
import os
import time
import multiprocessing
from flask import request
from flask import jsonify
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from multiprocessing import Queue
os.environ["LC_ALL"] = "C"
os.environ["LANG"] = "C"
is_wayland = "WAYLAND_DISPLAY" in os.environ
osd_corner_radius = os.environ.get("IPMPV_CORNER_RADIUS")
to_qt_queue = Queue()
from_qt_queue = Queue()
M3U_URL = os.environ.get('IPMPV_M3U_URL')
class OsdWidget(QWidget):
def __init__(self, channel_info, width=600, height=165, close_time=5, corner_radius=int(osd_corner_radius) if osd_corner_radius is not None else 15):
QFontDatabase.addApplicationFont('FiraSans-Regular.ttf')
QFontDatabase.addApplicationFont('FiraSans-Bold.ttf')
global is_wayland
super().__init__()
self.channel_info = channel_info
self.orig_width = width
self.orig_height = height
self.close_time = close_time
self.corner_radius = corner_radius
self.video_codec = None
self.audio_codec = None
self.video_res = None
self.interlaced = None
# Setup window
self.setWindowTitle("OSD")
self.setFixedSize(width, height)
# Check if we're running on Wayland
self.is_wayland = is_wayland
# Set appropriate window flags and size
if self.is_wayland:
# For Wayland, use fullscreen transparent approach
self.setWindowFlags(
Qt.FramelessWindowHint |
Qt.WindowStaysOnTopHint |
Qt.WindowDoesNotAcceptFocus
)
# Set fullscreen size
self.screen_geometry = QApplication.desktop().screenGeometry()
self.setFixedSize(self.screen_geometry.width(), self.screen_geometry.height())
# Calculate content positioning
self.content_x = (self.screen_geometry.width() - self.orig_width) // 2
self.content_y = 20 # 20px from top
else:
# For X11, use the original approach
self.setWindowFlags(
Qt.FramelessWindowHint |
Qt.WindowStaysOnTopHint |
Qt.X11BypassWindowManagerHint |
Qt.Tool |
Qt.ToolTip
)
self.setFixedSize(width, height)
self.content_x = 0
self.content_y = 0
# Enable transparency
self.setAttribute(Qt.WA_TranslucentBackground)
# Position window at the top center of the screen
self.position_window()
# Load logo if available
self.logo_pixmap = None
if channel_info["logo"]:
self.load_logo()
if self.is_wayland:
self.setAttribute(Qt.WA_TransparentForMouseEvents)
def position_window(self):
if self.is_wayland:
# For Wayland, we just position at 0,0 (fullscreen)
self.move(0, 0)
# Ensure window stays on top
self.stay_on_top_timer = QTimer(self)
self.stay_on_top_timer.timeout.connect(lambda: self.raise_())
self.stay_on_top_timer.start(100) # Check every second
else:
# For X11, center at top
screen_geometry = QApplication.desktop().screenGeometry()
x = (screen_geometry.width() - self.orig_width) // 2
y = 20 # 20px from top
self.setGeometry(x, y, self.orig_width, self.orig_height)
# X11 specific window hints
self.setAttribute(Qt.WA_X11NetWmWindowTypeNotification)
QTimer.singleShot(100, lambda: self.move(x, y))
QTimer.singleShot(500, lambda: self.move(x, y))
# Periodically ensure window stays on top
self.stay_on_top_timer = QTimer(self)
self.stay_on_top_timer.timeout.connect(lambda: self.raise_())
self.stay_on_top_timer.start(1000) # Check every second
def load_logo(self):
try:
response = requests.get(self.channel_info["logo"])
if response.ok:
pixmap = QPixmap()
pixmap.loadFromData(response.content)
if not pixmap.isNull():
self.logo_pixmap = pixmap.scaled(80, 80, Qt.KeepAspectRatio, Qt.SmoothTransformation)
self.update() # Trigger repaint
except Exception as e:
print(f"Failed to load logo: {e}")
def paintEvent(self, a0):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
if self.is_wayland:
# For Wayland, we're drawing the content in the right position on a fullscreen widget
self.draw_osd_content(painter, self.content_x, self.content_y)
else:
# For X11, we're drawing directly at (0,0) since the widget is already positioned
self.draw_osd_content(painter, 0, 0)
def draw_osd_content(self, painter, x_offset, y_offset):
# Create a path for rounded rectangle background
path = QPainterPath()
path.addRoundedRect(
x_offset, y_offset,
self.orig_width, self.orig_height,
self.corner_radius, self.corner_radius
)
# Fill the rounded rectangle with semi-transparent background
painter.setPen(Qt.NoPen)
painter.setBrush(QColor(0, 50, 100, 200)) # RGBA
painter.drawPath(path)
# Setup text drawing
painter.setPen(QColor(255, 255, 255))
try:
font = QFont("Fira Sans", 18)
font.setBold(True)
painter.setFont(font)
# Draw channel name
painter.drawText(x_offset + 20, y_offset + 40, self.channel_info["name"])
font.setPointSize(14)
font.setBold(False)
painter.setFont(font)
# Draw deinterlace status
painter.drawText(x_offset + 20, y_offset + 70, f"Deinterlacing {'on' if self.channel_info['deinterlace'] else 'off'}")
# Draw latency mode
painter.drawText(x_offset + 20, y_offset + 100, f"{'Low' if self.channel_info['low_latency'] else 'High'} latency")
# Draw codec badges if available
if self.video_codec:
self.draw_badge(painter, self.video_codec, x_offset + 80, y_offset + self.orig_height - 40)
if self.audio_codec:
self.draw_badge(painter, self.audio_codec, x_offset + 140, y_offset + self.orig_height - 40)
if self.video_res:
self.draw_badge(painter, f"{self.video_res}{self.interlaced if self.interlaced is not None else ''}", x_offset + 20, y_offset + self.orig_height - 40)
# Draw logo if available
if self.logo_pixmap:
painter.drawPixmap(x_offset + self.orig_width - 100, y_offset + 20, self.logo_pixmap)
except Exception as e:
print(f"Error in painting: {e}")
import traceback
traceback.print_exc()
def draw_badge(self, painter, text, x, y):
# Save current painter state
painter.save()
# Draw rounded badge
painter.setPen(QPen(QColor(255, 255, 255, 255), 2))
painter.setBrush(Qt.NoBrush)
# Use QPainterPath for consistent rounded corners
badge_path = QPainterPath()
badge_path.addRoundedRect(x, y, 48, 20, 7, 7)
painter.drawPath(badge_path)
# Draw text
painter.setPen(QColor(255, 255, 255))
font = painter.font()
font.setBold(True)
font.setPointSize(8)
painter.setFont(font)
# Center text in badge
font_metrics = painter.fontMetrics()
text_width = font_metrics.width(text)
text_height = font_metrics.height()
# We need to use integer coordinates for drawText, not floats
text_x = int(x + (48 - text_width) / 2)
text_y = int(y + text_height)
# Use the int, int, string version of drawText
painter.drawText(text_x, text_y, text)
# Restore painter state
painter.restore()
def update_codecs(self, video_codec, audio_codec, video_res, interlaced):
if video_codec:
self.video_codec = video_codec
if audio_codec:
self.audio_codec = audio_codec
if video_res:
self.video_res = video_res
if interlaced is not None:
self.interlaced = f"{'i' if interlaced else 'p'}"
self.update() # Trigger repaint
def close_widget(self):
# Stop any active timers
if hasattr(self, 'stay_on_top_timer') and self.stay_on_top_timer.isActive():
self.stay_on_top_timer.stop()
# Close the widget
self.hide()
def start_close_timer(self, seconds=5):
"""
Starts a timer to close the widget after the specified number of seconds.
Parameters:
seconds (int): Number of seconds before closing the widget (default: 3)
"""
# Cancel any existing close timer
if hasattr(self, 'close_timer') and self.close_timer.isActive():
self.close_timer.stop()
# Create and start a new timer
self.close_timer = QTimer(self)
self.close_timer.setSingleShot(True)
self.close_timer.timeout.connect(self.close_widget)
self.close_timer.start(seconds * 1000) # Convert seconds to milliseconds
osd = None
def qt_process():
"""Run Qt application in a separate process"""
from PyQt5.QtWidgets import QApplication
from PyQt5.QtCore import QTimer
app = QApplication(sys.argv)
osd = None
# Check the queue periodically for commands
def check_queue():
global osd
if not to_qt_queue.empty():
command = to_qt_queue.get()
if command['action'] == 'show_osd':
if osd is not None:
osd.close_widget()
# Create new OSD
osd = OsdWidget(command['channel_info'])
osd.show()
elif command['action'] == 'start_close':
if osd is not None:
osd.start_close_timer()
elif command['action'] == 'close_osd':
if osd is not None:
osd.close_widget()
osd = None
elif command['action'] == 'update_codecs':
if osd is not None:
osd.update_codecs(command['vcodec'], command['acodec'], command['video_res'], command['interlaced'])
# Schedule next check
QTimer.singleShot(100, check_queue)
# Start the queue check
check_queue()
# Run Qt event loop
app.exec_()
def get_channels():
if M3U_URL:
response = requests.get(M3U_URL)
else:
print("Error: IPMPV_M3U_URL not set. Please set this environment variable to the URL of your IPTV list, in M3U format.")
exit(1)
lines = response.text.splitlines()
channels = []
regex = re.compile(r'tvg-logo="(.*?)".*?group-title="(.*?)"', re.IGNORECASE)
for i in range(len(lines)):
if lines[i].startswith("#EXTINF"):
match = regex.search(lines[i])
logo = match.group(1) if match else ""
group = match.group(2) if match else "Other"
name = lines[i].split(",")[-1]
url = lines[i + 1]
channels.append({"name": name, "url": url, "logo": logo, "group": group})
return channels
def get_current_resolution():
global is_wayland
try:
if is_wayland:
wlr_randr_env = os.environ.copy()
output = subprocess.check_output(["wlr-randr"], universal_newlines=True, env=wlr_randr_env)
if "Composite-1" in output.split("\n")[0]:
for line in output.split("\n"):
if "720x480" in line and "current" in line:
return "480i"
elif "720x240" in line and "current" in line:
return "240p"
elif "720x576" in line and "current" in line:
return "576i"
elif "720x288" in line and "current" in line:
return "288p"
else:
xrandr_env = os.environ.copy()
output = subprocess.check_output(["xrandr"], universal_newlines=True, env=xrandr_env)
for line in output.split("\n"):
if "Composite-1" in line:
if "720x480" in line:
return "480i"
elif "720x240" in line:
return "240p"
elif "720x576" in line:
return "576i"
elif "720x288" in line:
return "288p"
except subprocess.CalledProcessError:
if is_wayland:
print("Error: Cannot get display resolution. Is this a wl-roots compatible compositor?")
else:
print("Error: Cannot get display resolution. Is an X session running?")
except FileNotFoundError:
if is_wayland:
print("Error: Could not find wlr-randr, resolution will be unknown")
else:
print("Error: Could not find xrandr, resolution will be unknown")
return "UNK"
def error_check(loglevel, component, message):
global player
print(f"[{loglevel}] {component}: {message}")
if loglevel == 'error' and (component == 'ffmpeg' or component == 'cplayer') and 'Failed' in message:
player.loadfile("./nosignal.png")
to_qt_queue.put({
'action': 'start_close'
})
player = mpv.MPV(
# it's a bit weird that i have to use the logs to get errors,
# but catch_errors is apparently broken
log_handler = error_check,
vo = 'gpu',
hwdec = 'auto-safe',
demuxer_lavf_o = 'reconnect=1',
deinterlace = 'no',
keepaspect = 'no',
geometry = '100%:100%',
fullscreen = 'yes',
loop_playlist = 'inf'
)
deinterlace = False
channels = get_channels()
current_index = None
retroarch_p = None
resolution = get_current_resolution()
low_latency = False
vcodec = None
acodec = None
video_res = None
interlaced = None
@player.property_observer('video-format')
def video_codec_observer(name, value):
global vcodec, acodec
if value:
vcodec = value.upper()
@player.property_observer('audio-codec-name')
def audio_codec_observer(name, value):
global acodec, vcodec
if value:
acodec = value.upper()
def play_channel(index):
global current_index, vcodec, acodec, video_res, interlaced
print(f"\n=== Starting channel change to index {index} ===")
to_qt_queue.put({
'action': 'close_osd'
})
print("Closed OSD")
vcodec = None
acodec = None
current_index = index % len(channels)
print(f"Playing channel: {channels[current_index]['name']} ({channels[current_index]['url']})")
try:
player.loadfile("./novideo.png")
player.wait_until_playing()
channel_info = {
"name": channels[current_index]["name"],
"deinterlace": deinterlace,
"low_latency": low_latency,
"logo": channels[current_index]["logo"]
}
to_qt_queue.put({
'action': 'show_osd',
'channel_info': channel_info
})
player.loadfile(channels[current_index]['url'])
time.sleep(0.5)
player.wait_until_playing()
video_params = player.video_params
video_frame_info = player.video_frame_info
if video_params and video_frame_info:
video_res = video_params.get('h')
interlaced = video_frame_info.get('interlaced')
to_qt_queue.put({
'action': 'update_codecs',
'vcodec': vcodec,
'acodec': acodec,
'video_res': video_res,
'interlaced': interlaced
})
to_qt_queue.put({
'action': 'start_close',
})
except Exception as e:
print(f"\033[91mError in play_channel: {str(e)}\033[0m")
traceback.print_exc()
app = flask.Flask(__name__)
# Flask routes here
@app.route("/")
def index():
grouped_channels = {}
for channel in channels:
grouped_channels.setdefault(channel["group"], []).append(channel)
flat_channel_list = [channel for channel in channels]
html = f"""
<html>
<head>
<title>IPMPV</title>
<style>
body {{ background-color: #111111; font-family: Fira Sans Regular, Arial, sans-serif; color: white; text-align: center; }}
.channel {{ display: flex; align-items: center; padding: 5px; }}
.channel img {{ width: 50px; height: auto; margin-right: 10px; }}
.group-container {{ display: flex; flex-wrap: wrap; justify-content: center; gap: 20px; }}
.group {{ display: flex; flex-direction: column; margin-top: 20px; font-size: 20px; font-weight: bold; }}
button {{
padding: 10px;
min-width: 200px;
margin: 5px;
font-size: 16px;
color: white;
border: none;
transition: background-color 0.2s, transform 0.1s;
background-color: #222222;
border-radius: 15px;
}}
button.input-btn {{
padding: 10px;
min-width: 200px;
margin: 5px 5px 5px 0;
font-size: 16px;
color: white;
border: none;
transition: background-color 0.2s, transform 0.1s;
background-color: #222222;
border-radius: 0;
border-top-right-radius: 15px;
border-bottom-right-radius: 15px;
}}
#latency-btn {{
padding: 10px;
min-width: 50px;
margin: 0;
font-size: 16px;
color: white;
border: none;
transition: background-color 0.2s, transform 0.1s;
border-radius: 0;
}}
input {{
padding: 10px;
min-width: 500px;
margin: 5px 0 5px 5px;
font-size: 16px;
color: white;
border: none;
transition: background-color 0.2s, transform 0.1s;
background-color: #303030;
border-top-left-radius: 15px;
border-bottom-left-radius: 15px;
}}
button:hover {{
background-color: #444444;
}}
button:active {{
background-color: #666666;
transform: scale(0.95);
}}
button.input-btn:active {{
background-color: #666666;
transform: none;
}}
button.OFF {{
background-color: #770000; /* Default OFF color */
}}
button.ON {{
background-color: #007700; /* Default OFF color */
}}
#osd-on-btn {{
min-width: 100px;
margin: 0;
border-radius: 0;
border-top-left-radius: 15px;
border-bottom-left-radius: 15px;
}}
#osd-off-btn {{
min-width: 100px;
margin: 0;
border-radius: 0;
border-top-right-radius: 15px;
border-bottom-right-radius: 15px;
}}
</style>
</head>
<body>
<h1>Welcome to IPMPV</h1>
<p>Current Channel: {channels[current_index]['name'] if current_index is not None else "None"}</p>
<button onclick="stopPlayer()">Stop</button>
"""
#<button onclick="changeChannel({(current_index - 1) % len(channels)})">Previous</button>
#<button onclick="changeChannel({(current_index + 1) % len(channels)})">Next</button>
global deinterlace
global low_latency
deinterlace_state = "ON" if deinterlace else "OFF"
retroarch_state = "ON" if retroarch_p and retroarch_p.poll() is None else "OFF"
html += f"""
<button id="retroarch-btn" class="{retroarch_state}" onclick="toggleRetroArch()"><span id="retroarch-state">{"Stop RetroArch" if retroarch_p and retroarch_p.poll() is None else "Start RetroArch"}</span></button>
<button id="deinterlace-btn" class="{deinterlace_state}" onclick="toggleDeinterlace()">Deinterlacing: <span id="deinterlace-state">{deinterlace_state}</span></button>
<button id="resolution-btn" onclick="toggleResolution()">Resolution: <span id="resolution-state">{resolution}</span></button>
<h2>Toggle OSD</h2>
<button id="osd-on-btn" onclick="showOSD()">on</button><button id="osd-off-btn" onclick="hideOSD()">off</button>
"""
html += f"""
<h2>Play Custom URL</h2>
<input type="text" id="custom-url" placeholder="Enter stream URL"><button id="latency-btn" class="{'ON' if low_latency else 'OFF'}" onclick="toggleLatency()"><span id="latency-state">{'Lo' if low_latency else 'Hi'}</span></button><button class="input-btn" onclick="playCustomURL()">Play</button>
<h2>All Channels</h2>
<div class="group-container">
"""
for group, ch_list in grouped_channels.items():
html += f'<div class="group">{group}'
for channel in ch_list:
index = flat_channel_list.index(channel) # Get correct global index
html += f'''
<div class="channel">
<img src="{channel['logo']}" onerror="this.style.display='none'">
<button onclick="changeChannel({index})">{channel['name']}</button>
</div>
'''
html += '</div>'
html += """
</div>
<script>
function playCustomURL() {
const url = document.getElementById("custom-url").value;
if (!url.trim()) return; // Ignore empty input
fetch(`/play_custom?url=${encodeURIComponent(url)}`)
.then(response => response.json())
.then(data => {
if (data.success) {
alert("Now playing: " + url);
} else {
alert("Error: " + data.error);
}
});
}
function toggleLatency() {
fetch(`/toggle_latency`)
.then(response => response.json())
.then(data => {
document.getElementById("latency-state").textContent = data.state ? "Lo" : "Hi";
document.getElementById("latency-btn").style.backgroundColor = data.state ? "#007700" : "#770000";
});
}
function toggleRetroArch() {
fetch(`/toggle_retroarch`)
.then(response => response.json())
.then(data => {
document.getElementById("retroarch-state").textContent = data.state ? "Stop RetroArch" : "Start RetroArch";
document.getElementById("retroarch-btn").style.backgroundColor = data.state ? "#007700" : "#770000";
});
}
function toggleResolution() {
fetch(`/toggle_resolution`)
.then(response => response.json())
.then(data => {
document.getElementById("resolution-state").textContent = data.res;
});
}
function stopPlayer() {
fetch(`/stop_player`).then(() => window.location.reload());
}
function changeChannel(index) {
fetch(`/channel?index=${index}`).then(() => window.location.reload());
}
function toggleDeinterlace() {
fetch(`/toggle_deinterlace`)
.then(response => response.json())
.then(data => {
document.getElementById("deinterlace-state").textContent = data.state ? "ON" : "OFF";
document.getElementById("deinterlace-btn").style.backgroundColor = data.state ? "#007700" : "#770000";
});
}
function showOSD() {
fetch(`/show_osd`).then(response => response.json())
}
function hideOSD() {
fetch(`/hide_osd`).then(response => response.json())
}
</script>
</body>
</html>
"""
return html
def is_valid_url(url):
return re.match(r"^(https?|rtmp|rtmps|udp|tcp):\/\/[\w\-]+(\.[\w\-]+)*(:\d+)?([\/?].*)?$", url) is not None
@app.route("/play_custom")
def play_custom():
global current_index
current_index = None
url = request.args.get("url")
if not url or not is_valid_url(url):
return jsonify(success=False, error="Invalid or unsupported URL")
player.loadfile(url)
return jsonify(success=True)
@app.route("/hide_osd")
def hide_osd():
to_qt_queue.put({
'action': 'close_osd',
})
return "", 204
@app.route("/show_osd")
def show_osd():
if current_index is not None:
channel_info = {
"name": channels[current_index]["name"],
"deinterlace": deinterlace,
"low_latency": low_latency,
"logo": channels[current_index]["logo"]
}
to_qt_queue.put({
'action': 'show_osd',
'channel_info': channel_info
})
to_qt_queue.put({
'action': 'update_codecs',
'vcodec': vcodec,
'acodec': acodec,
'video_res': video_res,
'interlaced': interlaced
})
return "", 204
@app.route("/channel")
def switch_channel():
index = int(request.args.get("index", current_index))
play_channel(index)
return "", 204
@app.route("/toggle_deinterlace")
def toggle_deinterlace():
global deinterlace
deinterlace = not deinterlace
if deinterlace:
player['vf'] = 'yadif=0'
else:
player['vf'] = ''
return jsonify(state=deinterlace)
@app.route("/stop_player")
def stop_player():
global current_index
global osd
current_index = None
to_qt_queue.put({
'action': 'close_osd',
})
player.stop()
return "", 204
@app.route("/toggle_retroarch")
def toggle_retroarch():
global retroarch_p
retroarch_pid = subprocess.run(["pgrep", "-fx", "retroarch"], stdout=subprocess.PIPE).stdout.strip()
if retroarch_pid:
print("Retroarch already open. Trying to close it.")
subprocess.run(["kill", retroarch_pid])
retroarch_p.terminate()
return jsonify(state=False)
else:
print("Launching RetroArch")
retroarch_env = os.environ.copy()
retroarch_env["MESA_GL_VERSION_OVERRIDE"] = "3.3"
retroarch_p = subprocess.Popen(["/usr/bin/flatpak","run","org.libretro.RetroArch"], env=retroarch_env)
return jsonify(state=True)
@app.route("/toggle_latency")
def toggle_latency():
global low_latency
low_latency = not low_latency
player['audio-buffer'] = '0' if low_latency else '0.2'
player['vd-lavc-threads'] = '1' if low_latency else '0'
player['cache-pause'] = 'no' if low_latency else 'yes'
player['demuxer-lavf-o'] = 'reconnect=1,fflags=+nobuffer' if low_latency else 'reconnect=1'
player['demuxer-lavf-probe-info'] = 'nostreams' if low_latency else 'auto'
player['demuxer-lavf-analyzeduration'] = '0.1' if low_latency else '0'
player['video-sync'] = 'audio'
player['interpolation'] = 'no'
player['video-latency-hacks'] = 'yes' if low_latency else 'no'
player['stream-buffer-size'] = '4k' if low_latency else '128k'
print("JSON: ",jsonify(state=low_latency))
return jsonify(state=low_latency)
@app.route("/toggle_resolution")
def toggle_resolution():
global resolution,is_wayland
new_res = ""
if is_wayland:
if resolution == "480i":
new_res = "720x240"
elif resolution == "240p":
new_res = "720x480"
elif resolution == "576i":
new_res = "720x288"
elif resolution == "288p":
new_res = "720x576"
else:
if resolution == "480i":
new_res = "720x240"
elif resolution == "240p":
new_res = "720x480i"
elif resolution == "576i":
new_res = "720x288"
elif resolution == "288p":
new_res = "720x576i"
if new_res:
if is_wayland:
wlr_randr_env = os.environ.copy()
wlr_randr_env["DISPLAY"] = ":0"
subprocess.run(["wlr-randr", "--output", "Composite-1", "--mode", new_res], check=False, env=wlr_randr_env)
resolution = get_current_resolution()
else:
xrandr_env = os.environ.copy()
xrandr_env["DISPLAY"] = ":0"
subprocess.run(["xrandr", "--output", "Composite-1", "--mode", new_res], check=False, env=xrandr_env)
resolution = get_current_resolution()
return jsonify(res=get_current_resolution())
if __name__ == "__main__":
# Start Qt process
qt_proc = multiprocessing.Process(target=qt_process)
qt_proc.daemon = True
qt_proc.start()
# Start Flask in main thread
app.run(host="0.0.0.0", port=5000)