diff --git a/plugins/multiview/__init__.py b/plugins/multiview/__init__.py index 5e746e1..3d74832 100644 --- a/plugins/multiview/__init__.py +++ b/plugins/multiview/__init__.py @@ -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.""" @@ -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() @@ -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: diff --git a/plugins/multiview/config.py b/plugins/multiview/config.py index cc0d6fb..8e8d17e 100644 --- a/plugins/multiview/config.py +++ b/plugins/multiview/config.py @@ -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 "" @@ -365,6 +365,7 @@ 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: @@ -372,15 +373,20 @@ def _build_layout_channel_options(n: int, settings: dict, ch_count: int, selecto 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: diff --git a/plugins/multiview/dash/api.py b/plugins/multiview/dash/api.py index 6e27c1d..9acac27 100644 --- a/plugins/multiview/dash/api.py +++ b/plugins/multiview/dash/api.py @@ -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 "" @@ -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) diff --git a/plugins/multiview/dash/static/assets/index-BpVIXxkP.js b/plugins/multiview/dash/static/assets/index-CFe5xHdB.js similarity index 96% rename from plugins/multiview/dash/static/assets/index-BpVIXxkP.js rename to plugins/multiview/dash/static/assets/index-CFe5xHdB.js index cee4223..ac9211a 100644 --- a/plugins/multiview/dash/static/assets/index-BpVIXxkP.js +++ b/plugins/multiview/dash/static/assets/index-CFe5xHdB.js @@ -148,4 +148,4 @@ Error generating stack: `+p.message+` * * This source code is licensed under the MIT license. * See the LICENSE file in the root directory of this source tree. - */const P2=[["path",{d:"M4 7l16 0",key:"svg-0"}],["path",{d:"M10 11l0 6",key:"svg-1"}],["path",{d:"M14 11l0 6",key:"svg-2"}],["path",{d:"M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12",key:"svg-3"}],["path",{d:"M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3",key:"svg-4"}]],_2=On("outline","trash","Trash",P2),wy="/dash/logo.png",T2=()=>localStorage.getItem("mv_token"),j2=t=>localStorage.setItem("mv_token",t),xy=()=>localStorage.removeItem("mv_token");let Ll=null;async function ro(t,{method:r="GET",body:o=null}={}){const s={"Content-Type":"application/json"},l=T2();l&&(s.Authorization=`Bearer ${l}`);const u=await fetch(t,{method:r,headers:s,body:o!==null?JSON.stringify(o):void 0});if(u.status===401){xy(),Ll&&Ll();const c=new Error("Session expired");throw c.status=401,c}return u}async function $2(t,r){const o=await fetch("/dash/api/auth/token",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:t,password:r})});if(!o.ok){const s=await o.json().catch(()=>({}));throw new Error(s.error||"Invalid credentials")}j2((await o.json()).access)}const O2=async()=>{const t=await ro("/dash/api/fields");if(!t.ok)throw new Error("Failed to load fields");return t.json()},I2=async()=>{const t=await ro("/dash/api/config");if(!t.ok)throw new Error("Failed to load config");return t.json()},Nl=async t=>{const r=await ro("/dash/api/config",{method:"PATCH",body:t});if(!r.ok){const o=await r.json().catch(()=>({}));throw new Error(o.error||"Save failed")}},M2=async()=>(await ro("/dash/api/refresh",{method:"POST"})).json(),Sy=async()=>(await ro("/dash/api/streams")).json(),ov=async t=>(await ro("/dash/api/streams/restart",{method:"POST",body:t!=null?{n:t}:{}})).json(),A2=async(t,r)=>(await ro("/dash/api/streams/restart",{method:"POST",body:{n:t,channel_idx:r}})).json(),D2=/^(video_encoder|multiview_\d+_(selector_type|channel_count|epg_source_mode))$/,L2=t=>D2.test(t);function z2({label:t,description:r}){return r?S.jsxs(Xt,{gap:4,align:"center",wrap:"nowrap",children:[S.jsx("span",{children:t}),S.jsx(is,{label:r,multiline:!0,maw:260,withArrow:!0,position:"top-start",events:{hover:!0,focus:!0,touch:!0},children:S.jsx(jn,{size:"xs",variant:"transparent",c:"dimmed",tabIndex:-1,style:{cursor:"default",flexShrink:0},children:S.jsx(S2,{size:13})})})]}):t}function tf({field:t,value:r,onChange:o}){var l;const s={label:S.jsx(z2,{label:t.label,description:t.description})};switch(t.type){case"select":return S.jsx(ud,{...s,data:((l=t.options)==null?void 0:l.map(u=>({value:String(u.value),label:u.label})))??[],value:String(r??t.default??""),onChange:o,allowDeselect:!1});case"number":return S.jsx(id,{...s,value:r??t.default??0,min:t.min,max:t.max,placeholder:t.placeholder,onChange:o});case"string":return S.jsx(ya,{...s,value:r??t.default??"",placeholder:t.placeholder,onChange:u=>o(u.currentTarget.value)});default:return null}}function by(t,r){const o=x.useRef({});return x.useCallback(async(s,l,u)=>{t({[s]:l});const c=async()=>{try{await Nl({[s]:l}),yt.show({message:"Saved",color:"green",autoClose:1500}),L2(s)&&await r()}catch(d){yt.show({title:"Save failed",message:d.message,color:"red",autoClose:4e3})}};clearTimeout(o.current[s]),u?await c():o.current[s]=setTimeout(c,700)},[t,r])}function F2({onLogin:t}){const[r,o]=x.useState(""),[s,l]=x.useState(""),[u,c]=x.useState(""),[d,m]=x.useState(!1);async function h(v){v.preventDefault(),m(!0),c("");try{await $2(r,s),t()}catch(g){c(g.message||"Login failed")}finally{m(!1)}}return S.jsx(qi,{mih:"100dvh",bg:"dark.8",children:S.jsxs(Kn,{align:"center",gap:"lg",w:340,px:"md",children:[S.jsx("img",{src:wy,style:{height:40,width:"auto"},alt:"Multiview"}),S.jsx(kt,{size:"sm",c:"dimmed",ta:"center",children:"Sign in with your Dispatcharr credentials. The account must have permission to modify plugin settings."}),S.jsx(Qo,{withBorder:!0,p:"xl",radius:"md",w:"100%",children:S.jsx("form",{onSubmit:h,children:S.jsxs(Kn,{gap:"sm",children:[u&&S.jsx(sa,{icon:S.jsx(vy,{size:16}),color:"red",variant:"light",py:"xs",children:u}),S.jsx(ya,{label:"Username",autoComplete:"username",value:r,onChange:v=>o(v.currentTarget.value),required:!0}),S.jsx(sd,{label:"Password",autoComplete:"current-password",value:s,onChange:v=>l(v.currentTarget.value),required:!0}),S.jsx(wt,{type:"submit",loading:d,fullWidth:!0,mt:"xs",children:"Sign in"})]})})})]})})}const B2=/^Layout\s+\d+[:\s]*/i,V2=t=>({...t,label:t.label.replace(B2,"")});function W2({fields:t,warnings:r,settings:o,onSettingsChange:s,onFieldsReload:l}){const[u,c]=x.useState(!1),d=by(s,l);return S.jsxs(Kn,{gap:"xs",children:[r.map(m=>S.jsxs(sa,{icon:S.jsx(vy,{size:16}),color:"yellow",variant:"light",children:[S.jsx(kt,{size:"sm",fw:500,children:m.label}),m.description&&S.jsx(kt,{size:"xs",c:"dimmed",mt:2,children:m.description})]},m.id)),S.jsxs(Qo,{withBorder:!0,radius:"md",children:[S.jsx(Xn,{w:"100%",p:"md",onClick:()=>c(m=>!m),children:S.jsxs(Xt,{justify:"space-between",children:[S.jsx(kt,{size:"sm",fw:600,children:"Global Settings"}),u?S.jsx(gy,{size:16}):S.jsx(yy,{size:16})]})}),S.jsx(hf,{in:u,children:S.jsx("div",{style:{display:"grid",gridTemplateColumns:"repeat(auto-fill, minmax(280px, 1fr))",gap:"var(--mantine-spacing-sm)",padding:"var(--mantine-spacing-md)",paddingTop:0},children:t.map(m=>S.jsx(tf,{field:m,value:o[m.id],onChange:h=>d(m.id,h,m.type!=="string")},m.id))})})]})]})}function H2(t,r){const o=[],s=[],l=[];let u=null,c=null;for(const d of t){const m=d.id.replace(`multiview_${r}_`,"");["name","layout","selector_type"].includes(m)?o.push(d):m==="channel_count"?c=d:m==="audio_source"?u=d:m.startsWith("epg_")?l.push(d):s.push(d)}return{base:o,channels:s,epg:l,audioSource:u,channelCountField:c}}function U2({n:t,fields:r,settings:o,isLast:s,onSettingsChange:l,onFieldsReload:u,onRemove:c,onChannelCountChange:d}){const[m,h]=x.useState(!1),[v,g]=x.useState(!1),y=by(l,u),{base:w,channels:C,epg:E,audioSource:R,channelCountField:N}=H2(r.map(V2),t),j=o[`multiview_${t}_name`]||`Multiview ${t}`,T=o[`multiview_${t}_channel_count`]??(N==null?void 0:N.default)??4,O=(N==null?void 0:N.min)??2,A=(N==null?void 0:N.max)??9;function F(H){return H.length?S.jsx("div",{style:{display:"grid",gridTemplateColumns:"repeat(auto-fill, minmax(280px, 1fr))",gap:"var(--mantine-spacing-sm)"},children:H.map(z=>S.jsx(tf,{field:z,value:o[z.id],onChange:Q=>y(z.id,Q,z.type!=="string")},z.id))}):null}return S.jsxs(aa,{withBorder:!0,radius:"md",p:0,children:[S.jsx(Xn,{w:"100%",p:"md",onClick:()=>g(H=>!H),children:S.jsxs(Xt,{justify:"space-between",children:[S.jsxs(Xt,{gap:"xs",children:[v?S.jsx(gy,{size:16}):S.jsx(yy,{size:16}),S.jsxs(kt,{fw:600,children:["Layout ",t,": ",j]})]}),S.jsx(wt,{size:"xs",color:"red",variant:"subtle",leftSection:S.jsx(_2,{size:14}),style:{visibility:s?"visible":"hidden"},onClick:H=>{H.stopPropagation(),h(!0)},children:"Remove"})]})}),S.jsxs(Kt,{opened:m,onClose:()=>h(!1),title:`Remove Layout ${t}`,size:"xs",centered:!0,children:[S.jsxs(kt,{size:"sm",children:["Remove Layout ",t,": ",j,"? This cannot be undone."]}),S.jsxs(Xt,{mt:"md",justify:"flex-end",children:[S.jsx(wt,{variant:"default",onClick:()=>h(!1),children:"Cancel"}),S.jsx(wt,{color:"red",onClick:()=>{h(!1),c()},children:"Remove"})]})]}),S.jsx(hf,{in:v,children:S.jsxs(Kn,{gap:"md",p:"md",pt:0,children:[F(w),(C.length>0||N)&&S.jsxs(S.Fragment,{children:[S.jsx(Al,{label:S.jsxs(Xt,{gap:4,align:"center",children:[S.jsx(kt,{size:"xs",c:"dimmed",children:"Channels"}),S.jsx(jn,{size:"xs",variant:"subtle",disabled:T<=O,onClick:()=>d(-1),children:S.jsx(C2,{size:10})}),S.jsx(kt,{size:"xs",fw:600,w:14,ta:"center",children:T}),S.jsx(jn,{size:"xs",variant:"subtle",disabled:T>=A,onClick:()=>d(1),children:S.jsx(k2,{size:10})})]}),labelPosition:"left"}),F(C)]}),R&&S.jsx(tf,{field:R,value:o[R.id],onChange:H=>y(R.id,H,!0)}),E.length>0&&S.jsxs(S.Fragment,{children:[S.jsx(Al,{label:"EPG",labelPosition:"left"}),F(E)]})]})})]})}function G2({opened:t,onClose:r,settings:o}){const[s,l]=x.useState([]),[u,c]=x.useState({}),[d,m]=x.useState(null),h=x.useCallback(async()=>{try{const y=await Sy();l(y.active??[])}catch{}},[]);x.useEffect(()=>{if(!t)return;h();const y=setInterval(h,4e3);return()=>clearInterval(y)},[t,h]);const v=async(y,w,C)=>{c(E=>({...E,[y]:!0}));try{await w(),yt.show({message:C,color:"blue",autoClose:2e3}),await h()}catch(E){yt.show({title:"Failed",message:E.message,color:"red",autoClose:4e3})}finally{c(E=>({...E,[y]:!1}))}},g=(y,w,C,E,R)=>m({title:y,message:w,confirmLabel:C,color:E,onConfirm:R});return S.jsxs(Kt,{opened:t,onClose:r,title:"Active Multiviews",size:"sm",centered:!0,children:[S.jsx(Cy,{action:d,onClose:()=>m(null)}),s.length===0?S.jsx(kt,{size:"sm",c:"dimmed",children:"No multiviews currently active."}):S.jsx(Kn,{gap:"md",children:s.map(({n:y,channels:w})=>{const C=o[`multiview_${y}_name`]||`Multiview ${y}`;return S.jsxs("div",{children:[S.jsxs(Xt,{justify:"space-between",mb:6,children:[S.jsx(kt,{size:"sm",fw:600,children:C}),S.jsx(wt,{size:"xs",color:"orange",variant:"subtle",leftSection:S.jsx(rv,{size:12}),loading:u[`${y}`],onClick:()=>g("Reload",`Reload ${C}? The compositor will restart and active players will reconnect. Some players may buffer, stutter, or require a manual refresh depending on how they handle stream interruptions.`,"Reload","orange",()=>v(`${y}`,()=>ov(y),`${C} reloaded`)),children:"Reload"})]}),S.jsx(Kn,{gap:4,pl:"sm",children:(w??[]).map(({idx:E,name:R})=>S.jsxs(Xt,{justify:"space-between",children:[S.jsx(kt,{size:"xs",c:"dimmed",children:R}),S.jsx(wt,{size:"xs",variant:"subtle",leftSection:S.jsx(ef,{size:11}),loading:u[`${y}-${E}`],onClick:()=>g("Reconnect",`Reconnect ${R}?`,"Reconnect","blue",()=>v(`${y}-${E}`,()=>A2(y,E),`${R} reconnecting`)),children:"Reconnect"})]},E))})]},y)})}),s.length>1&&S.jsx(wt,{fullWidth:!0,mt:"md",color:"orange",variant:"light",leftSection:S.jsx(rv,{size:14}),loading:u.all,onClick:()=>g("Reload All",`Reload all ${s.length} multiviews? All compositors will restart and active players will reconnect. Some players may buffer, stutter, or require a manual refresh depending on how they handle stream interruptions.`,"Reload All","orange",()=>v("all",()=>ov(null),`${s.length} multiviews reloaded`)),children:"Reload All"})]})}function Cy({action:t,onClose:r}){return t?S.jsxs(Kt,{opened:!!t,onClose:r,title:t.title,size:"sm",centered:!0,children:[S.jsx(kt,{size:"sm",children:t.message}),S.jsxs(Xt,{mt:"md",justify:"flex-end",children:[S.jsx(wt,{variant:"default",onClick:r,children:"Cancel"}),S.jsx(wt,{color:t.color??"blue",loading:t.loading,onClick:()=>{r(),t.onConfirm()},children:t.confirmLabel??"Confirm"})]})]}):null}function Y2(){const[t,r]=x.useState(!!localStorage.getItem("mv_token")),[o,s]=x.useState(null),[l,u]=x.useState({}),[c,d]=x.useState(""),[m,h]=x.useState(!1),[v,g]=x.useState(!1),[y,w]=x.useState(0),[C,E]=x.useState(null),R=x.useCallback(async()=>{try{const[X,K]=await Promise.all([O2(),I2()]);s(X);const Z=K.settings??{},te=[...X.global??[],...(X.layouts??[]).flatMap(B=>B.fields)],J={...Z};for(const B of te)!(B.id in J)&&B.default!==void 0&&(J[B.id]=B.default);u(J)}catch(X){X.status!==401&&d(X.message)}},[]);x.useEffect(()=>{t&&R()},[t,R]),x.useEffect(()=>{if(!t)return;const X=async()=>{try{const Z=await Sy();w((Z.active??[]).length)}catch{}};X();const K=setInterval(X,5e3);return()=>clearInterval(K)},[t]);const N=x.useCallback(X=>u(K=>({...K,...X})),[]);async function j(X,K){const Z=`multiview_${X}_channel_count`,te=(l[Z]??4)+K;try{await Nl({[Z]:te}),await R()}catch(J){yt.show({title:"Failed",message:J.message,color:"red",autoClose:4e3})}}async function T(){const X=((o==null?void 0:o.layout_count)??1)+1,K={multiview_count:X,[`multiview_${X}_name`]:`Multiview ${X}`,[`multiview_${X}_layout`]:"auto",[`multiview_${X}_selector_type`]:"classic",[`multiview_${X}_channel_count`]:4,[`multiview_${X}_epg_source_mode`]:"dummy"};try{await Nl(K),await R(),yt.show({message:`Layout ${X} added`,color:"green",autoClose:2e3})}catch(Z){yt.show({title:"Failed",message:Z.message,color:"red",autoClose:4e3})}}async function O(X){const K=(o==null?void 0:o.layout_count)??1;if(K<=1)return;const Z=Object.fromEntries(Object.keys(l).filter(te=>te.startsWith(`multiview_${X}_`)).map(te=>[te,null]));Z.multiview_count=K-1;try{await Nl(Z),await R(),yt.show({message:`Layout ${X} removed`,color:"green",autoClose:2e3})}catch(te){yt.show({title:"Failed",message:te.message,color:"red",autoClose:4e3})}}async function A(){h(!0);try{const X=await M2();yt.show({message:X.message||"M3U & EPG refreshed",color:X.status==="success"?"green":"red",autoClose:3e3})}catch(X){yt.show({title:"Refresh failed",message:X.message,color:"red",autoClose:4e3})}finally{h(!1)}}function F(){xy(),r(!1),s(null),u({})}if(x.useEffect(()=>(Ll=()=>{r(!1),s(null),u({})},()=>{Ll=null}),[]),!t)return S.jsx(F2,{onLogin:()=>r(!0)});if(c)return S.jsx(qi,{mih:"100dvh",children:S.jsx(kt,{c:"red",children:c})});if(!o)return S.jsx(qi,{mih:"100dvh",children:S.jsx(to,{})});const{warnings:H=[],global:z=[],layouts:Q=[],layout_count:q}=o;return S.jsxs(yn,{header:{height:56},children:[S.jsx(yn.Header,{children:S.jsxs(Xt,{h:"100%",px:"md",justify:"space-between",wrap:"nowrap",children:[S.jsx(nd,{src:wy,h:32,w:"auto",style:{flexShrink:0}}),S.jsxs(Xt,{gap:"xs",wrap:"nowrap",children:[S.jsxs(wt,{size:"sm",leftSection:S.jsx(nv,{size:16}),color:y>0?"teal":void 0,variant:y>0?"filled":"default",styles:y>0?{root:{"--button-bg":"var(--mantine-color-teal-9)","--button-hover":"var(--mantine-color-teal-8)"}}:void 0,onClick:()=>g(!0),visibleFrom:"xs",children:["Streams",y>0?` (${y})`:""]}),S.jsx(jn,{size:"lg",color:y>0?"teal":void 0,variant:y>0?"filled":"default",styles:y>0?{root:{"--button-bg":"var(--mantine-color-teal-9)","--button-hover":"var(--mantine-color-teal-8)"}}:void 0,onClick:()=>g(!0),hiddenFrom:"xs","aria-label":"Active Streams",children:S.jsx(nv,{size:18})}),S.jsx(wt,{size:"sm",leftSection:S.jsx(ef,{size:16}),loading:m,onClick:()=>E({title:"Refresh M3U & EPG",confirmLabel:"Refresh",color:"blue",message:"Regenerate the M3U playlist and sync EPG data now?",onConfirm:A}),visibleFrom:"xs",children:"Refresh M3U & EPG"}),S.jsx(jn,{size:"lg",variant:"default",loading:m,hiddenFrom:"xs","aria-label":"Refresh M3U & EPG",onClick:()=>E({title:"Refresh M3U & EPG",confirmLabel:"Refresh",color:"blue",message:"Regenerate the M3U playlist and sync EPG data now?",onConfirm:A}),children:S.jsx(ef,{size:18})}),S.jsx(wt,{size:"sm",variant:"subtle",onClick:F,children:"Logout"})]})]})}),S.jsx(yn.Main,{children:S.jsxs(Kn,{p:"md",maw:860,mx:"auto",children:[S.jsx(W2,{fields:z,warnings:H,settings:l,onSettingsChange:N,onFieldsReload:R}),S.jsx(kt,{size:"xs",tt:"uppercase",fw:700,c:"dimmed",mt:"sm",children:"Layouts"}),Q.map(({n:X,fields:K})=>S.jsx(U2,{n:X,fields:K,settings:l,isLast:X===q&&q>1,onSettingsChange:N,onFieldsReload:R,onRemove:()=>O(X),onChannelCountChange:Z=>j(X,Z)},X)),S.jsx(wt,{variant:"default",onClick:T,children:"+ Add Layout"})]})}),S.jsx(G2,{opened:v,onClose:()=>g(!1),settings:l}),S.jsx(Cy,{action:C,onClose:()=>E(null)})]})}Nw.createRoot(document.getElementById("root")).render(S.jsx(x.StrictMode,{children:S.jsxs(_v,{defaultColorScheme:"dark",children:[S.jsx(tr,{position:"bottom-right"}),S.jsx(Y2,{})]})})); + */const P2=[["path",{d:"M4 7l16 0",key:"svg-0"}],["path",{d:"M10 11l0 6",key:"svg-1"}],["path",{d:"M14 11l0 6",key:"svg-2"}],["path",{d:"M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12",key:"svg-3"}],["path",{d:"M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3",key:"svg-4"}]],_2=On("outline","trash","Trash",P2),wy="/dash/logo.png",T2=()=>localStorage.getItem("mv_token"),j2=t=>localStorage.setItem("mv_token",t),xy=()=>localStorage.removeItem("mv_token");let Ll=null;async function ro(t,{method:r="GET",body:o=null}={}){const s={"Content-Type":"application/json"},l=T2();l&&(s.Authorization=`Bearer ${l}`);const u=await fetch(t,{method:r,headers:s,body:o!==null?JSON.stringify(o):void 0});if(u.status===401){xy(),Ll&&Ll();const c=new Error("Session expired");throw c.status=401,c}return u}async function $2(t,r){const o=await fetch("/dash/api/auth/token",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:t,password:r})});if(!o.ok){const s=await o.json().catch(()=>({}));throw new Error(s.error||"Invalid credentials")}j2((await o.json()).access)}const O2=async()=>{const t=await ro("/dash/api/fields");if(!t.ok)throw new Error("Failed to load fields");return t.json()},I2=async()=>{const t=await ro("/dash/api/config");if(!t.ok)throw new Error("Failed to load config");return t.json()},Nl=async t=>{const r=await ro("/dash/api/config",{method:"PATCH",body:t});if(!r.ok){const o=await r.json().catch(()=>({}));throw new Error(o.error||"Save failed")}},M2=async()=>(await ro("/dash/api/refresh",{method:"POST"})).json(),Sy=async()=>(await ro("/dash/api/streams")).json(),ov=async t=>(await ro("/dash/api/streams/restart",{method:"POST",body:t!=null?{n:t}:{}})).json(),A2=async(t,r)=>(await ro("/dash/api/streams/restart",{method:"POST",body:{n:t,channel_idx:r}})).json(),D2=/^(video_encoder|multiview_\d+_(selector_type|channel_count|epg_source_mode))$/,L2=t=>D2.test(t);function z2({label:t,description:r}){return r?S.jsxs(Xt,{gap:4,align:"center",wrap:"nowrap",children:[S.jsx("span",{children:t}),S.jsx(is,{label:r,multiline:!0,maw:260,withArrow:!0,position:"top-start",events:{hover:!0,focus:!0,touch:!0},children:S.jsx(jn,{size:"xs",variant:"transparent",c:"dimmed",tabIndex:-1,style:{cursor:"default",flexShrink:0},children:S.jsx(S2,{size:13})})})]}):t}function tf({field:t,value:r,onChange:o}){const s={label:S.jsx(z2,{label:t.label,description:t.description})};switch(t.type){case"select":{const l=new Set,u=(t.options??[]).reduce((c,d)=>{const m=String(d.value);return l.has(m)||(l.add(m),c.push({value:m,label:d.label})),c},[]);return S.jsx(ud,{...s,data:u,value:String(r??t.default??""),onChange:o,allowDeselect:!1})}case"number":return S.jsx(id,{...s,value:r??t.default??0,min:t.min,max:t.max,placeholder:t.placeholder,onChange:o});case"string":return S.jsx(ya,{...s,value:r??t.default??"",placeholder:t.placeholder,onChange:l=>o(l.currentTarget.value)});default:return null}}function by(t,r){const o=x.useRef({});return x.useCallback(async(s,l,u)=>{t({[s]:l});const c=async()=>{try{await Nl({[s]:l}),yt.show({message:"Saved",color:"green",autoClose:1500}),L2(s)&&await r()}catch(d){yt.show({title:"Save failed",message:d.message,color:"red",autoClose:4e3})}};clearTimeout(o.current[s]),u?await c():o.current[s]=setTimeout(c,700)},[t,r])}function F2({onLogin:t}){const[r,o]=x.useState(""),[s,l]=x.useState(""),[u,c]=x.useState(""),[d,m]=x.useState(!1);async function h(v){v.preventDefault(),m(!0),c("");try{await $2(r,s),t()}catch(g){c(g.message||"Login failed")}finally{m(!1)}}return S.jsx(qi,{mih:"100dvh",bg:"dark.8",children:S.jsxs(Kn,{align:"center",gap:"lg",w:340,px:"md",children:[S.jsx("img",{src:wy,style:{height:40,width:"auto"},alt:"Multiview"}),S.jsx(kt,{size:"sm",c:"dimmed",ta:"center",children:"Sign in with your Dispatcharr credentials. The account must have permission to modify plugin settings."}),S.jsx(Qo,{withBorder:!0,p:"xl",radius:"md",w:"100%",children:S.jsx("form",{onSubmit:h,children:S.jsxs(Kn,{gap:"sm",children:[u&&S.jsx(sa,{icon:S.jsx(vy,{size:16}),color:"red",variant:"light",py:"xs",children:u}),S.jsx(ya,{label:"Username",autoComplete:"username",value:r,onChange:v=>o(v.currentTarget.value),required:!0}),S.jsx(sd,{label:"Password",autoComplete:"current-password",value:s,onChange:v=>l(v.currentTarget.value),required:!0}),S.jsx(wt,{type:"submit",loading:d,fullWidth:!0,mt:"xs",children:"Sign in"})]})})})]})})}const B2=/^Layout\s+\d+[:\s]*/i,V2=t=>({...t,label:t.label.replace(B2,"")});function W2({fields:t,warnings:r,settings:o,onSettingsChange:s,onFieldsReload:l}){const[u,c]=x.useState(!1),d=by(s,l);return S.jsxs(Kn,{gap:"xs",children:[r.map(m=>S.jsxs(sa,{icon:S.jsx(vy,{size:16}),color:"yellow",variant:"light",children:[S.jsx(kt,{size:"sm",fw:500,children:m.label}),m.description&&S.jsx(kt,{size:"xs",c:"dimmed",mt:2,children:m.description})]},m.id)),S.jsxs(Qo,{withBorder:!0,radius:"md",children:[S.jsx(Xn,{w:"100%",p:"md",onClick:()=>c(m=>!m),children:S.jsxs(Xt,{justify:"space-between",children:[S.jsx(kt,{size:"sm",fw:600,children:"Global Settings"}),u?S.jsx(gy,{size:16}):S.jsx(yy,{size:16})]})}),S.jsx(hf,{in:u,children:S.jsx("div",{style:{display:"grid",gridTemplateColumns:"repeat(auto-fill, minmax(280px, 1fr))",gap:"var(--mantine-spacing-sm)",padding:"var(--mantine-spacing-md)",paddingTop:0},children:t.map(m=>S.jsx(tf,{field:m,value:o[m.id],onChange:h=>d(m.id,h,m.type!=="string")},m.id))})})]})]})}function H2(t,r){const o=[],s=[],l=[];let u=null,c=null;for(const d of t){const m=d.id.replace(`multiview_${r}_`,"");["name","layout","selector_type"].includes(m)?o.push(d):m==="channel_count"?c=d:m==="audio_source"?u=d:m.startsWith("epg_")?l.push(d):s.push(d)}return{base:o,channels:s,epg:l,audioSource:u,channelCountField:c}}function U2({n:t,fields:r,settings:o,isLast:s,onSettingsChange:l,onFieldsReload:u,onRemove:c,onChannelCountChange:d}){const[m,h]=x.useState(!1),[v,g]=x.useState(!1),y=by(l,u),{base:w,channels:C,epg:E,audioSource:R,channelCountField:N}=H2(r.map(V2),t),j=o[`multiview_${t}_name`]||`Multiview ${t}`,T=o[`multiview_${t}_channel_count`]??(N==null?void 0:N.default)??4,O=(N==null?void 0:N.min)??2,A=(N==null?void 0:N.max)??9;function F(H){return H.length?S.jsx("div",{style:{display:"grid",gridTemplateColumns:"repeat(auto-fill, minmax(280px, 1fr))",gap:"var(--mantine-spacing-sm)"},children:H.map(z=>S.jsx(tf,{field:z,value:o[z.id],onChange:Q=>y(z.id,Q,z.type!=="string")},z.id))}):null}return S.jsxs(aa,{withBorder:!0,radius:"md",p:0,children:[S.jsx(Xn,{w:"100%",p:"md",onClick:()=>g(H=>!H),children:S.jsxs(Xt,{justify:"space-between",children:[S.jsxs(Xt,{gap:"xs",children:[v?S.jsx(gy,{size:16}):S.jsx(yy,{size:16}),S.jsxs(kt,{fw:600,children:["Layout ",t,": ",j]})]}),S.jsx(wt,{size:"xs",color:"red",variant:"subtle",leftSection:S.jsx(_2,{size:14}),style:{visibility:s?"visible":"hidden"},onClick:H=>{H.stopPropagation(),h(!0)},children:"Remove"})]})}),S.jsxs(Kt,{opened:m,onClose:()=>h(!1),title:`Remove Layout ${t}`,size:"xs",centered:!0,children:[S.jsxs(kt,{size:"sm",children:["Remove Layout ",t,": ",j,"? This cannot be undone."]}),S.jsxs(Xt,{mt:"md",justify:"flex-end",children:[S.jsx(wt,{variant:"default",onClick:()=>h(!1),children:"Cancel"}),S.jsx(wt,{color:"red",onClick:()=>{h(!1),c()},children:"Remove"})]})]}),S.jsx(hf,{in:v,children:S.jsxs(Kn,{gap:"md",p:"md",pt:0,children:[F(w),(C.length>0||N)&&S.jsxs(S.Fragment,{children:[S.jsx(Al,{label:S.jsxs(Xt,{gap:4,align:"center",children:[S.jsx(kt,{size:"xs",c:"dimmed",children:"Channels"}),S.jsx(jn,{size:"xs",variant:"subtle",disabled:T<=O,onClick:()=>d(-1),children:S.jsx(C2,{size:10})}),S.jsx(kt,{size:"xs",fw:600,w:14,ta:"center",children:T}),S.jsx(jn,{size:"xs",variant:"subtle",disabled:T>=A,onClick:()=>d(1),children:S.jsx(k2,{size:10})})]}),labelPosition:"left"}),F(C)]}),R&&S.jsx(tf,{field:R,value:o[R.id],onChange:H=>y(R.id,H,!0)}),E.length>0&&S.jsxs(S.Fragment,{children:[S.jsx(Al,{label:"EPG",labelPosition:"left"}),F(E)]})]})})]})}function G2({opened:t,onClose:r,settings:o}){const[s,l]=x.useState([]),[u,c]=x.useState({}),[d,m]=x.useState(null),h=x.useCallback(async()=>{try{const y=await Sy();l(y.active??[])}catch{}},[]);x.useEffect(()=>{if(!t)return;h();const y=setInterval(h,4e3);return()=>clearInterval(y)},[t,h]);const v=async(y,w,C)=>{c(E=>({...E,[y]:!0}));try{await w(),yt.show({message:C,color:"blue",autoClose:2e3}),await h()}catch(E){yt.show({title:"Failed",message:E.message,color:"red",autoClose:4e3})}finally{c(E=>({...E,[y]:!1}))}},g=(y,w,C,E,R)=>m({title:y,message:w,confirmLabel:C,color:E,onConfirm:R});return S.jsxs(Kt,{opened:t,onClose:r,title:"Active Multiviews",size:"sm",centered:!0,children:[S.jsx(Cy,{action:d,onClose:()=>m(null)}),s.length===0?S.jsx(kt,{size:"sm",c:"dimmed",children:"No multiviews currently active."}):S.jsx(Kn,{gap:"md",children:s.map(({n:y,channels:w})=>{const C=o[`multiview_${y}_name`]||`Multiview ${y}`;return S.jsxs("div",{children:[S.jsxs(Xt,{justify:"space-between",mb:6,children:[S.jsx(kt,{size:"sm",fw:600,children:C}),S.jsx(wt,{size:"xs",color:"orange",variant:"subtle",leftSection:S.jsx(rv,{size:12}),loading:u[`${y}`],onClick:()=>g("Reload",`Reload ${C}? The compositor will restart and active players will reconnect. Some players may buffer, stutter, or require a manual refresh depending on how they handle stream interruptions.`,"Reload","orange",()=>v(`${y}`,()=>ov(y),`${C} reloaded`)),children:"Reload"})]}),S.jsx(Kn,{gap:4,pl:"sm",children:(w??[]).map(({idx:E,name:R})=>S.jsxs(Xt,{justify:"space-between",children:[S.jsx(kt,{size:"xs",c:"dimmed",children:R}),S.jsx(wt,{size:"xs",variant:"subtle",leftSection:S.jsx(ef,{size:11}),loading:u[`${y}-${E}`],onClick:()=>g("Reconnect",`Reconnect ${R}?`,"Reconnect","blue",()=>v(`${y}-${E}`,()=>A2(y,E),`${R} reconnecting`)),children:"Reconnect"})]},E))})]},y)})}),s.length>1&&S.jsx(wt,{fullWidth:!0,mt:"md",color:"orange",variant:"light",leftSection:S.jsx(rv,{size:14}),loading:u.all,onClick:()=>g("Reload All",`Reload all ${s.length} multiviews? All compositors will restart and active players will reconnect. Some players may buffer, stutter, or require a manual refresh depending on how they handle stream interruptions.`,"Reload All","orange",()=>v("all",()=>ov(null),`${s.length} multiviews reloaded`)),children:"Reload All"})]})}function Cy({action:t,onClose:r}){return t?S.jsxs(Kt,{opened:!!t,onClose:r,title:t.title,size:"sm",centered:!0,children:[S.jsx(kt,{size:"sm",children:t.message}),S.jsxs(Xt,{mt:"md",justify:"flex-end",children:[S.jsx(wt,{variant:"default",onClick:r,children:"Cancel"}),S.jsx(wt,{color:t.color??"blue",loading:t.loading,onClick:()=>{r(),t.onConfirm()},children:t.confirmLabel??"Confirm"})]})]}):null}function Y2(){const[t,r]=x.useState(!!localStorage.getItem("mv_token")),[o,s]=x.useState(null),[l,u]=x.useState({}),[c,d]=x.useState(""),[m,h]=x.useState(!1),[v,g]=x.useState(!1),[y,w]=x.useState(0),[C,E]=x.useState(null),R=x.useCallback(async()=>{try{const[X,K]=await Promise.all([O2(),I2()]);s(X);const Z=K.settings??{},te=[...X.global??[],...(X.layouts??[]).flatMap(B=>B.fields)],J={...Z};for(const B of te)!(B.id in J)&&B.default!==void 0&&(J[B.id]=B.default);u(J)}catch(X){X.status!==401&&d(X.message)}},[]);x.useEffect(()=>{t&&R()},[t,R]),x.useEffect(()=>{if(!t)return;const X=async()=>{try{const Z=await Sy();w((Z.active??[]).length)}catch{}};X();const K=setInterval(X,5e3);return()=>clearInterval(K)},[t]);const N=x.useCallback(X=>u(K=>({...K,...X})),[]);async function j(X,K){const Z=`multiview_${X}_channel_count`,te=(l[Z]??4)+K;try{await Nl({[Z]:te}),await R()}catch(J){yt.show({title:"Failed",message:J.message,color:"red",autoClose:4e3})}}async function T(){const X=((o==null?void 0:o.layout_count)??1)+1,K={multiview_count:X,[`multiview_${X}_name`]:`Multiview ${X}`,[`multiview_${X}_layout`]:"auto",[`multiview_${X}_selector_type`]:"classic",[`multiview_${X}_channel_count`]:4,[`multiview_${X}_epg_source_mode`]:"dummy"};try{await Nl(K),await R(),yt.show({message:`Layout ${X} added`,color:"green",autoClose:2e3})}catch(Z){yt.show({title:"Failed",message:Z.message,color:"red",autoClose:4e3})}}async function O(X){const K=(o==null?void 0:o.layout_count)??1;if(K<=1)return;const Z=Object.fromEntries(Object.keys(l).filter(te=>te.startsWith(`multiview_${X}_`)).map(te=>[te,null]));Z.multiview_count=K-1;try{await Nl(Z),await R(),yt.show({message:`Layout ${X} removed`,color:"green",autoClose:2e3})}catch(te){yt.show({title:"Failed",message:te.message,color:"red",autoClose:4e3})}}async function A(){h(!0);try{const X=await M2();yt.show({message:X.message||"M3U & EPG refreshed",color:X.status==="success"?"green":"red",autoClose:3e3})}catch(X){yt.show({title:"Refresh failed",message:X.message,color:"red",autoClose:4e3})}finally{h(!1)}}function F(){xy(),r(!1),s(null),u({})}if(x.useEffect(()=>(Ll=()=>{r(!1),s(null),u({})},()=>{Ll=null}),[]),!t)return S.jsx(F2,{onLogin:()=>r(!0)});if(c)return S.jsx(qi,{mih:"100dvh",children:S.jsx(kt,{c:"red",children:c})});if(!o)return S.jsx(qi,{mih:"100dvh",children:S.jsx(to,{})});const{warnings:H=[],global:z=[],layouts:Q=[],layout_count:q}=o;return S.jsxs(yn,{header:{height:56},children:[S.jsx(yn.Header,{children:S.jsxs(Xt,{h:"100%",px:"md",justify:"space-between",wrap:"nowrap",children:[S.jsx(nd,{src:wy,h:32,w:"auto",style:{flexShrink:0}}),S.jsxs(Xt,{gap:"xs",wrap:"nowrap",children:[S.jsxs(wt,{size:"sm",leftSection:S.jsx(nv,{size:16}),color:y>0?"teal":void 0,variant:y>0?"filled":"default",styles:y>0?{root:{"--button-bg":"var(--mantine-color-teal-9)","--button-hover":"var(--mantine-color-teal-8)"}}:void 0,onClick:()=>g(!0),visibleFrom:"xs",children:["Streams",y>0?` (${y})`:""]}),S.jsx(jn,{size:"lg",color:y>0?"teal":void 0,variant:y>0?"filled":"default",styles:y>0?{root:{"--button-bg":"var(--mantine-color-teal-9)","--button-hover":"var(--mantine-color-teal-8)"}}:void 0,onClick:()=>g(!0),hiddenFrom:"xs","aria-label":"Active Streams",children:S.jsx(nv,{size:18})}),S.jsx(wt,{size:"sm",leftSection:S.jsx(ef,{size:16}),loading:m,onClick:()=>E({title:"Refresh M3U & EPG",confirmLabel:"Refresh",color:"blue",message:"Regenerate the M3U playlist and sync EPG data now?",onConfirm:A}),visibleFrom:"xs",children:"Refresh M3U & EPG"}),S.jsx(jn,{size:"lg",variant:"default",loading:m,hiddenFrom:"xs","aria-label":"Refresh M3U & EPG",onClick:()=>E({title:"Refresh M3U & EPG",confirmLabel:"Refresh",color:"blue",message:"Regenerate the M3U playlist and sync EPG data now?",onConfirm:A}),children:S.jsx(ef,{size:18})}),S.jsx(wt,{size:"sm",variant:"subtle",onClick:F,children:"Logout"})]})]})}),S.jsx(yn.Main,{children:S.jsxs(Kn,{p:"md",maw:860,mx:"auto",children:[S.jsx(W2,{fields:z,warnings:H,settings:l,onSettingsChange:N,onFieldsReload:R}),S.jsx(kt,{size:"xs",tt:"uppercase",fw:700,c:"dimmed",mt:"sm",children:"Layouts"}),Q.map(({n:X,fields:K})=>S.jsx(U2,{n:X,fields:K,settings:l,isLast:X===q&&q>1,onSettingsChange:N,onFieldsReload:R,onRemove:()=>O(X),onChannelCountChange:Z=>j(X,Z)},X)),S.jsx(wt,{variant:"default",onClick:T,children:"+ Add Layout"})]})}),S.jsx(G2,{opened:v,onClose:()=>g(!1),settings:l}),S.jsx(Cy,{action:C,onClose:()=>E(null)})]})}Nw.createRoot(document.getElementById("root")).render(S.jsx(x.StrictMode,{children:S.jsxs(_v,{defaultColorScheme:"dark",children:[S.jsx(tr,{position:"bottom-right"}),S.jsx(Y2,{})]})})); diff --git a/plugins/multiview/dash/static/index.html b/plugins/multiview/dash/static/index.html index ab6ff5f..9aa86ff 100644 --- a/plugins/multiview/dash/static/index.html +++ b/plugins/multiview/dash/static/index.html @@ -9,7 +9,7 @@ Multiview - + diff --git a/plugins/multiview/layouts.py b/plugins/multiview/layouts.py index ee504dc..fd14978 100644 --- a/plugins/multiview/layouts.py +++ b/plugins/multiview/layouts.py @@ -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 @@ -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 diff --git a/plugins/multiview/plugin.json b/plugins/multiview/plugin.json index a49559e..a26f810 100644 --- a/plugins/multiview/plugin.json +++ b/plugins/multiview/plugin.json @@ -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", diff --git a/plugins/multiview/server.py b/plugins/multiview/server.py index faf74f0..907c14c 100644 --- a/plugins/multiview/server.py +++ b/plugins/multiview/server.py @@ -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")])