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
60 changes: 37 additions & 23 deletions plugins/multiview/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,21 @@ def _autostart(self):
@property
def fields(self):
"""Regenerate fields from current DB settings on every request."""
config_mod = _config()
try:
from apps.plugins.models import PluginConfig
cfg = PluginConfig.objects.get(key=PLUGIN_DB_KEY)
settings = cfg.settings
settings, changed1 = config_mod.ensure_layout_order(cfg.settings)
settings, changed2 = config_mod.reconcile_layout_count(settings)
settings, changed3 = config_mod.ensure_custom_layout_order(settings)
if changed1 or changed2 or changed3:
cfg.settings = settings
cfg.save()
except Exception:
settings = {}
return _config().build_plugin_fields(settings)
settings, _changed = config_mod.ensure_layout_order({})
settings, _changed = config_mod.reconcile_layout_count(settings)
settings, _changed = config_mod.ensure_custom_layout_order(settings)
return config_mod.build_plugin_fields(settings)

# Action dispatcher

Expand All @@ -192,20 +200,28 @@ def _deps():
# generate_m3u

def _generate_m3u(self) -> dict:
config_mod = _config()
try:
from apps.plugins.models import PluginConfig
cfg = PluginConfig.objects.get(key=PLUGIN_DB_KEY)
settings = cfg.settings
settings, changed1 = config_mod.ensure_layout_order(cfg.settings)
settings, changed2 = config_mod.reconcile_layout_count(settings)
settings, changed3 = config_mod.ensure_custom_layout_order(settings)
if changed1 or changed2 or changed3:
cfg.settings = settings
cfg.save()
except Exception:
settings = {}
mv_count = max(1, int(settings.get("multiview_count", 1)))
settings, _changed = config_mod.ensure_layout_order({})
settings, _changed = config_mod.reconcile_layout_count(settings)
settings, _changed = config_mod.ensure_custom_layout_order(settings)
order = settings.get("multiview_order", [])

lines = ["#EXTM3U"]
for n in range(1, mv_count + 1):
for n in order:
name = settings.get(f"multiview_{n}_name", f"Multiview {n}") or f"Multiview {n}"
safe_name = name.replace('"', "'") # quotes break EXTINF attribute parsing
stream_url = f"http://127.0.0.1:{DEFAULT_SERVER_PORT}/stream/{n}"
lines.append(f'#EXTINF:-1 tvg-id="multiview_{n}" tvg-name="{safe_name}",{safe_name}')
lines.append(f'#EXTINF:-1 tvg-id="mv-{n}" tvg-name="{safe_name}",{safe_name}')
lines.append(stream_url)

m3u_content = "\n".join(lines) + "\n"
Expand Down Expand Up @@ -248,30 +264,28 @@ def _generate_m3u(self) -> dict:
}

def _refresh_epg_then_m3u(self, account_id, source_id) -> None:
"""Refresh EPG first, then M3U, in sequence.

Firing both refreshes at once collides on Dispatcharr's shared celery DB
connection ("the last operation didn't produce records (command status:
INSERT 0 N)"). A celery chain serializes them; EPG first so program data
is current before the M3U account sync runs.

refresh_single_m3u_account internally calls refresh_m3u_groups(full_refresh=True)
which calls process_groups, which hits a Python 3.13 / Django ORM incompatibility
(StopIteration raised inside QuerySet.__iter__ generator -> RuntimeError).
Calling refresh_m3u_groups directly with full_refresh=False skips process_groups
and avoids the crash.
"""Refresh EPG first, then M3U, once EPG finishes (success OR error).

Firing both at once collides on Dispatcharr's shared celery DB connection
("the last operation didn't produce records (command status: INSERT 0 N)"),
so M3U must wait for EPG to finish. A plain celery chain achieves that but
aborts the M3U task if EPG raises -- which silently skipped the M3U refresh
(and all its Dispatcharr-side websocket progress) whenever EPG refresh
failed. link_error() also fires the M3U task on EPG's failure path, so it
always runs once EPG is done, regardless of outcome.
"""
try:
from celery import chain
from apps.m3u.tasks import refresh_single_m3u_account
if source_id is not None:
from apps.epg.tasks import refresh_epg_data
chain(refresh_epg_data.si(source_id),
refresh_single_m3u_account.si(account_id)).delay()
epg_sig = refresh_epg_data.si(source_id)
epg_sig.link_error(refresh_single_m3u_account.si(account_id))
chain(epg_sig, refresh_single_m3u_account.si(account_id)).delay()
else:
refresh_single_m3u_account.delay(account_id)
except Exception as e: # noqa: BLE001
logger.warning(f"Could not trigger EPG/M3U refresh chain: {e}")
logger.warning(f"Could not trigger EPG/M3U refresh: {e}")

# start_server

Expand Down
32 changes: 18 additions & 14 deletions plugins/multiview/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,21 +87,28 @@ def _even(v):


def fit_into_tile(frame, w, h, valign="center", halign="center"):
"""Scale a decoded frame into a w x h yuv420p tile, preserving aspect
ratio (content is never cropped). Letterbox/pillarbox padding is
centered by default, or pushed to one edge via valign ("center"/"top"/
"bottom") and halign ("center"/"left"/"right") so a tile's content can
be made to touch an adjacent tile's content with no gap at the shared
edge, leaving any padding on the outer edge instead."""
"""Scale a decoded frame preserving aspect ratio (content is never
cropped). Letterbox/pillarbox padding is centered by default, or pushed
to one edge via valign ("center"/"top"/"bottom") and halign
("center"/"left"/"right") so a tile's content can be made to touch an
adjacent tile's content with no gap at the shared edge, leaving any
padding on the outer edge instead.

Returns (Y, U, V, ox, oy, tw, th) -- the content-sized planes and their
placement within the w x h tile -- rather than a full w x h buffer with
black padding baked in, so the compositor can blit only the real content
and leave letterbox/pillarbox padding alone (showing whatever's already
on the persistent canvas, e.g. the background image, instead of painting
opaque black over it every frame).
"""
sw, sh = frame.width, frame.height
if sw <= 0 or sh <= 0:
return black_planes(w, h)
return (*black_planes(w, h), 0, 0, w, h)
scale = min(w / sw, h / sh)
tw, th = _even(sw * scale), _even(sh * scale)
tw, th = min(tw, w), min(th, h)
sf = frame.reformat(width=tw, height=th, format="yuv420p")
sy, su, sv = yuv_planes_from_frame(sf, tw, th)
Y, U, V = black_planes(w, h)
if halign == "left":
ox = 0
elif halign == "right":
Expand All @@ -114,10 +121,7 @@ def fit_into_tile(frame, w, h, valign="center", halign="center"):
oy = (h - th) & ~1
else:
oy = ((h - th) // 2) & ~1
Y[oy:oy + th, ox:ox + tw] = sy
U[oy // 2:oy // 2 + th // 2, ox // 2:ox // 2 + tw // 2] = su
V[oy // 2:oy // 2 + th // 2, ox // 2:ox // 2 + tw // 2] = sv
return (Y, U, V)
return (sy, su, sv, ox, oy, tw, th)


class Channel:
Expand All @@ -136,7 +140,7 @@ def __init__(self, spec):
self.featured = bool(spec.get("featured", False))
self.valign = spec.get("valign", "center")
self.halign = spec.get("halign", "center")
self.fallback = black_planes(self.w, self.h)
self.fallback = (*black_planes(self.w, self.h), 0, 0, self.w, self.h)
self.latest = self.fallback
self.fresh_until = 0.0
logo = spec.get("logo")
Expand Down Expand Up @@ -188,7 +192,7 @@ def _make_fallback(self, logo):
break
except Exception as e: # noqa: BLE001
log(f"logo decode failed for {self.name}: {e}")
return (Y, U, V)
return (Y, U, V, 0, 0, self.w, self.h)

def _load_logo(self, logo):
"""Load logo in background and swap self.fallback when ready."""
Expand Down
58 changes: 53 additions & 5 deletions plugins/multiview/compositor_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@

# channel.py sets up the vendored PyAV sys.path as a side effect of import;
# numpy must be imported after so it finds the vendored build.
from channel import Channel, AUDIO_RATE, AUDIO_LAYOUT, log, _yuv_planes # noqa: E402
from channel import Channel, AUDIO_RATE, AUDIO_LAYOUT, log, _yuv_planes, yuv_planes_from_frame, _even # noqa: E402

import av # noqa: E402 (vendored, already on sys.path via the channel import above)
import numpy as np # noqa: E402

from parameters import fps_fraction, build_encoder_cmd, validate_encoder # noqa: E402
Expand Down Expand Up @@ -121,6 +122,31 @@ def audio_feeder(track, fd, stop):
time.sleep(0.02)


# ---------------------------------------------------------------- background image

def _load_background(path, out_w, out_h):
"""Decode a still image and scale+center-crop it to exactly out_w x out_h
(aspect-preserving cover, like CSS background-size: cover), returning
(Y, U, V) planes ready to seed the canvas once at startup. Same PyAV
decode-to-YUV technique as channel.py's _make_fallback (logo image),
just cover-fit instead of contain-fit since this fills the whole frame
rather than a small padded tile.
"""
with av.open(path) as c:
for frame in c.decode(video=0):
scale = max(out_w / frame.width, out_h / frame.height)
sw, sh = _even(round(frame.width * scale)), _even(round(frame.height * scale))
rf = frame.reformat(width=sw, height=sh, format="yuv420p")
y, u, v = yuv_planes_from_frame(rf, sw, sh)
ox = ((sw - out_w) // 2) & ~1
oy = ((sh - out_h) // 2) & ~1
Y = np.ascontiguousarray(y[oy:oy + out_h, ox:ox + out_w])
U = np.ascontiguousarray(u[oy // 2:(oy + out_h) // 2, ox // 2:(ox + out_w) // 2])
V = np.ascontiguousarray(v[oy // 2:(oy + out_h) // 2, ox // 2:(ox + out_w) // 2])
return (Y, U, V)
return None


# ---------------------------------------------------------------- main

def main():
Expand Down Expand Up @@ -181,6 +207,24 @@ def pump_out():
Uc[:] = 128
Vc[:] = 128

# Seed the canvas with a background image once, if configured. Kept as
# an immutable reference copy (bg_buf) alongside the live canvas: each
# frame, every tile's rect is restored from this copy before its actual
# content is blitted on top, so letterbox/pillarbox padding (and any
# gap a layout leaves uncovered) shows the background instead of a
# stale opaque-black bar painted over it by a previous frame.
bg_path = cfg.get("background")
if bg_path:
try:
bg = _load_background(bg_path, out_w, out_h)
if bg:
Yc[:], Uc[:], Vc[:] = bg
except Exception as e: # noqa: BLE001
log(f"background image load failed ({bg_path}): {e}")

bg_buf = cbuf.copy()
bg_Y, bg_U, bg_V = _yuv_planes(bg_buf, out_w, out_h)

start = time.monotonic()
n = 0
log_at = start + 30.0
Expand All @@ -190,11 +234,15 @@ def pump_out():
try:
while not stop.is_set():
for t in channels:
Yt, Ut, Vt = t.current()
Yt, Ut, Vt, ox, oy, tw, th = t.current()
x, y, w, h = t.x, t.y, t.w, t.h
Yc[y:y + h, x:x + w] = Yt
Uc[y // 2:(y + h) // 2, x // 2:(x + w) // 2] = Ut
Vc[y // 2:(y + h) // 2, x // 2:(x + w) // 2] = Vt
Yc[y:y + h, x:x + w] = bg_Y[y:y + h, x:x + w]
Uc[y // 2:(y + h) // 2, x // 2:(x + w) // 2] = bg_U[y // 2:(y + h) // 2, x // 2:(x + w) // 2]
Vc[y // 2:(y + h) // 2, x // 2:(x + w) // 2] = bg_V[y // 2:(y + h) // 2, x // 2:(x + w) // 2]
px, py = x + ox, y + oy
Yc[py:py + th, px:px + tw] = Yt
Uc[py // 2:(py + th) // 2, px // 2:(px + tw) // 2] = Ut
Vc[py // 2:(py + th) // 2, px // 2:(px + tw) // 2] = Vt
if not _write_all(video_w, memoryview(cbuf)):
break
n += 1
Expand Down
Loading
Loading