Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions plugins/multiview/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ def _epg():
return _load_submodule("epg")


def _close_db_connections():
"""Release Django DB connections opened outside Django's own request cycle.

Background threads/loops here never go through Django's request_finished
signal, so connections they open are never cleaned up on their own.
"""
try:
from django.db import close_old_connections
close_old_connections()
except Exception:
pass


class Plugin:
"""Dispatcharr Plugin: Multiview stream tiling via FFmpeg."""

Expand Down Expand Up @@ -114,12 +127,16 @@ def __init__(self):
self._autostart()
except Exception as e:
logger.warning(f"Multiview server auto-start skipped: {e}")
finally:
_close_db_connections()

def _auto_repair_pyav(self):
try:
self._deps().maybe_auto_install()
except Exception as e: # noqa: BLE001
logger.warning(f"Multiview PyAV auto-repair skipped: {e}")
finally:
_close_db_connections()

def _autostart(self):
existing = _server().get_server()
Expand Down Expand Up @@ -285,6 +302,8 @@ def _refresh_loop(self, interval_secs: int):
self._generate_m3u()
except Exception as e:
logger.warning(f"Multiview auto-refresh failed: {e}")
finally:
_close_db_connections()

def _schedule_auto_refresh(self, interval_hours: int):
if interval_hours <= 0:
Expand Down
10 changes: 8 additions & 2 deletions plugins/multiview/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ def _build_channel_options() -> list:
opts = [{"value": "_none", "label": "Select a channel"}]
try:
from apps.channels.models import Channel
for ch in Channel.objects.order_by("channel_number").values("id", "name", "channel_number"):
for ch in Channel.objects.order_by("channel_number").values("id", "name", "channel_number").distinct():
if ch["id"] in excluded:
continue
num = int(ch["channel_number"]) if ch["channel_number"] is not None else ""
Expand All @@ -365,22 +365,28 @@ def _build_channel_options() -> list:
def _build_layout_channel_options(n: int, settings: dict, ch_count: int, selector_type: str, regex_pattern: str) -> list:
"""Return channel options scoped to the channels actually in layout n."""
opts = [{"value": "_none", "label": "Select a channel"}]
seen = set()
try:
from apps.channels.models import Channel
if selector_type == "regex" and regex_pattern:
for ch in (
Channel.objects.filter(name__iregex=regex_pattern)
.order_by("channel_number")[:ch_count]
.values("id", "name", "channel_number")
.distinct()
):
if ch["id"] in seen:
continue
seen.add(ch["id"])
num = int(ch["channel_number"]) if ch["channel_number"] is not None else ""
opts.append({"value": str(ch["id"]), "label": f"{num} - {ch['name']}"})
else:
for m in range(1, ch_count + 1):
ch_id = settings.get(f"multiview_{n}_channel_{m}", "_none")
if ch_id and ch_id != "_none":
if ch_id and ch_id != "_none" and ch_id not in seen:
try:
ch = Channel.objects.values("id", "name", "channel_number").get(id=int(ch_id))
seen.add(ch_id)
num = int(ch["channel_number"]) if ch["channel_number"] is not None else ""
opts.append({"value": str(ch["id"]), "label": f"{num} - {ch['name']}"})
except Channel.DoesNotExist:
Expand Down
4 changes: 2 additions & 2 deletions plugins/multiview/dash/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ def handle_channels(environ, start_response):
pass

channels = []
for ch in Channel.objects.order_by("channel_number").values("id", "name", "channel_number"):
for ch in Channel.objects.order_by("channel_number").values("id", "name", "channel_number").distinct():
if ch["id"] in excluded:
continue
num = int(ch["channel_number"]) if ch["channel_number"] is not None else ""
Expand Down Expand Up @@ -209,7 +209,7 @@ def handle_refresh(environ, start_response):
break
if plugin_mod is None:
return _json_error(start_response, "503 Service Unavailable", "Plugin module not found")
result = plugin_mod.Plugin()._generate_m3u()
result = plugin_mod.Plugin.__new__(plugin_mod.Plugin)._generate_m3u()
return _json_ok(start_response, result)
except Exception as e:
logger.error(f"Refresh failed: {e}", exc_info=True)
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion plugins/multiview/dash/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<link rel="icon" type="image/png" href="/dash/logo.png" />
<link rel="apple-touch-icon" href="/dash/icon-512.png" />
<title>Multiview</title>
<script type="module" crossorigin src="/dash/assets/index-BpVIXxkP.js"></script>
<script type="module" crossorigin src="/dash/assets/index-CFe5xHdB.js"></script>
<link rel="stylesheet" crossorigin href="/dash/assets/index-CKoNYPy6.css">
</head>
<body>
Expand Down
18 changes: 16 additions & 2 deletions plugins/multiview/layouts.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ def tile_rects(layout: str, n: int, out_w: int, out_h: int) -> list:


def _auto_grid_rects(n: int, out_w: int, out_h: int) -> list:
"""Square-ish grid; last partial row is horizontally centered."""
"""Square-ish grid; last partial row is horizontally centered.

Content in the top row is pushed down and the bottom row pushed up (same
idea as the side-stack valign trick in _featured_rects) so aspect-ratio
letterboxing collects at the outer top/bottom edges instead of doubling
up where rows meet.
"""
cols = math.ceil(math.sqrt(n))
rows = math.ceil(n / cols)
tile_w = out_w // cols
Expand All @@ -54,7 +60,15 @@ def _auto_grid_rects(n: int, out_w: int, out_h: int) -> list:
is_last = r == rows - 1 and empty_cells > 0
x = c * tile_w + (offset_x if is_last else 0)
y = r * tile_h
rects.append((x, y, tile_w, tile_h, "center", "center"))
if rows == 1:
valign = "center"
elif r == 0:
valign = "bottom"
elif r == rows - 1:
valign = "top"
else:
valign = "center"
rects.append((x, y, tile_w, tile_h, valign, "center"))
return rects


Expand Down
2 changes: 1 addition & 1 deletion plugins/multiview/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "Tile multiple Dispatcharr channel streams into multi-view outputs using FFmpeg",
"author": "sethwv",

"version": "0.3.0",
"version": "0.3.1",
"min_dispatcharr_version": "v0.27.0",

"discord_thread": "https://discord.com/channels/1340492560220684331/1509200002407465001",
Expand Down
17 changes: 17 additions & 0 deletions plugins/multiview/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,12 +169,29 @@ def wsgi_app(self, environ, start_response):
path = environ.get("PATH_INFO", "")
loopback = environ.get("REMOTE_ADDR", "") in ("127.0.0.1", "::1")

remote = environ.get("REMOTE_ADDR", "")
loopback = remote in ("127.0.0.1", "::1")

if path == "/health":
if deny := self._loopback_only(loopback, start_response):
return deny
start_response("200 OK", [("Content-Type", "text/plain")])
return [b"OK\n"]

try:
return self._route(path, loopback, environ, start_response)
finally:
# Everything below here can touch Django's ORM (settings, channel
# lookups, plugin config) outside of Django's own request cycle,
# which never runs Django's usual request_finished cleanup. Do it
# ourselves so connections don't accumulate and eventually go stale.
try:
from django.db import close_old_connections
close_old_connections()
except Exception:
pass

def _route(self, path, loopback, environ, start_response):
is_dash_path = path == "/dash" or path.startswith("/dash/") or path.startswith("/api/")
if is_dash_path and _settings().get("dash_enabled", "disabled") != "enabled":
start_response("404 Not Found", [("Content-Type", "text/plain")])
Expand Down
Loading