e.__isTeleport,bs=Symbol("_leaveCb");function oi(e,t){e.shapeFlag&6&&e.component?(e.transition=t,oi(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function we(e,t){return W(e)?Ce({name:e.name},t,{setup:e}):e}function jr(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function _i(e,t){let n;return!!((n=Object.getOwnPropertyDescriptor(e,t))&&!n.configurable)}const Un=new WeakMap;function mn(e,t,n,s,i=!1){if(G(e)){e.forEach((C,y)=>mn(C,t&&(G(t)?t[y]:t),n,s,i));return}if(Jt(s)&&!i){s.shapeFlag&512&&s.type.__asyncResolved&&s.component.subTree.component&&mn(e,t,n,s.component.subTree);return}const r=s.shapeFlag&4?fs(s.component):s.el,o=i?null:r,{i:a,r:l}=e,u=t&&t.r,c=a.refs===ae?a.refs={}:a.refs,p=a.setupState,g=ne(p),v=p===ae?dr:C=>_i(c,C)?!1:se(g,C),T=(C,y)=>!(y&&_i(c,y));if(u!=null&&u!==l){if(wi(t),ce(u))c[u]=null,v(u)&&(p[u]=null);else if(Te(u)){const C=t;T(u,C.k)&&(u.value=null),C.k&&(c[C.k]=null)}}if(W(l))Tn(l,a,12,[o,c]);else{const C=ce(l),y=Te(l);if(C||y){const $=()=>{if(e.f){const E=C?v(l)?p[l]:c[l]:T()||!e.k?l.value:c[e.k];if(i)G(E)&&Ys(E,r);else if(G(E))E.includes(r)||E.push(r);else if(C)c[l]=[r],v(l)&&(p[l]=c[l]);else{const N=[r];T(l,e.k)&&(l.value=N),e.k&&(c[e.k]=N)}}else C?(c[l]=o,v(l)&&(p[l]=o)):y&&(T(l,e.k)&&(l.value=o),e.k&&(c[e.k]=o))};if(o){const E=()=>{$(),Un.delete(e)};E.id=-1,Un.set(e,E),De(E,n)}else wi(e),$()}}}function wi(e){const t=Un.get(e);t&&(t.flags|=8,Un.delete(e))}ts().requestIdleCallback;ts().cancelIdleCallback;const Jt=e=>!!e.type.__asyncLoader,Gr=e=>e.type.__isKeepAlive;function Ol(e,t){Kr(e,"a",t)}function Il(e,t){Kr(e,"da",t)}function Kr(e,t,n=Me){const s=e.__wdc||(e.__wdc=()=>{let i=n;for(;i;){if(i.isDeactivated)return;i=i.parent}return e()});if(rs(t,s,n),n){let i=n.parent;for(;i&&i.parent;)Gr(i.parent.vnode)&&Nl(s,t,n,i),i=i.parent}}function Nl(e,t,n,s){const i=rs(t,e,s,!0);zr(()=>{Ys(s[t],i)},n)}function rs(e,t,n=Me,s=!1){if(n){const i=n[e]||(n[e]=[]),r=t.__weh||(t.__weh=(...o)=>{bt();const a=Pn(n),l=Xe(t,n,e,o);return a(),_t(),l});return s?i.unshift(r):i.push(r),r}}const Et=e=>(t,n=Me)=>{(!Sn||e==="sp")&&rs(e,(...s)=>t(...s),n)},Ll=Et("bm"),os=Et("m"),Dl=Et("bu"),Hl=Et("u"),ls=Et("bum"),zr=Et("um"),Vl=Et("sp"),$l=Et("rtg"),Fl=Et("rtc");function Bl(e,t=Me){rs("ec",e,t)}const Wr="components";function Ul(e,t){return Yr(Wr,e,!0,t)||e}const qr=Symbol.for("v-ndc");function jl(e){return ce(e)?Yr(Wr,e,!1)||e:e||qr}function Yr(e,t,n=!0,s=!1){const i=Se||Me;if(i){const r=i.type;{const a=Ma(r,!1);if(a&&(a===t||a===Ne(t)||a===Zn(Ne(t))))return r}const o=xi(i[e]||r[e],t)||xi(i.appContext[e],t);return!o&&s?r:o}}function xi(e,t){return e&&(e[t]||e[Ne(t)]||e[Zn(Ne(t))])}function Ft(e,t,n,s){let i;const r=n,o=G(e);if(o||ce(e)){const a=o&&$t(e);let l=!1,u=!1;a&&(l=!Ke(e),u=wt(e),e=ns(e)),i=new Array(e.length);for(let c=0,p=e.length;ct(a,l,void 0,r));else{const a=Object.keys(e);i=new Array(a.length);for(let l=0,u=a.length;l0;return t!=="default"&&(n.name=t),A(),We(j,null,[L("slot",n,s&&s())],u?-2:64)}let r=e[t];r&&r._c&&(r._d=!1),A();const o=r&&Jr(r(n)),a=n.key||o&&o.key,l=We(j,{key:(a&&!Je(a)?a:`_${t}`)+(!o&&s?"_fb":"")},o||(s?s():[]),o&&e._===1?64:-2);return l.scopeId&&(l.slotScopeIds=[l.scopeId+"-s"]),r&&r._c&&(r._d=!0),l}function Jr(e){return e.some(t=>En(t)?!(t.type===xt||t.type===j&&!Jr(t.children)):!0)?e:null}const Hs=e=>e?yo(e)?fs(e):Hs(e.parent):null,gn=Ce(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Hs(e.parent),$root:e=>Hs(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Xr(e),$forceUpdate:e=>e.f||(e.f=()=>{ii(e.update)}),$nextTick:e=>e.n||(e.n=is.bind(e.proxy)),$watch:e=>Tl.bind(e)}),_s=(e,t)=>e!==ae&&!e.__isScriptSetup&&se(e,t),Gl={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:n,setupState:s,data:i,props:r,accessCache:o,type:a,appContext:l}=e;if(t[0]!=="$"){const g=o[t];if(g!==void 0)switch(g){case 1:return s[t];case 2:return i[t];case 4:return n[t];case 3:return r[t]}else{if(_s(s,t))return o[t]=1,s[t];if(i!==ae&&se(i,t))return o[t]=2,i[t];if(se(r,t))return o[t]=3,r[t];if(n!==ae&&se(n,t))return o[t]=4,n[t];Vs&&(o[t]=0)}}const u=gn[t];let c,p;if(u)return t==="$attrs"&&Re(e.attrs,"get",""),u(e);if((c=a.__cssModules)&&(c=c[t]))return c;if(n!==ae&&se(n,t))return o[t]=4,n[t];if(p=l.config.globalProperties,se(p,t))return p[t]},set({_:e},t,n){const{data:s,setupState:i,ctx:r}=e;return _s(i,t)?(i[t]=n,!0):s!==ae&&se(s,t)?(s[t]=n,!0):se(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(r[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:s,appContext:i,props:r,type:o}},a){let l;return!!(n[a]||e!==ae&&a[0]!=="$"&&se(e,a)||_s(t,a)||se(r,a)||se(s,a)||se(gn,a)||se(i.config.globalProperties,a)||(l=o.__cssModules)&&l[a])},defineProperty(e,t,n){return n.get!=null?e._.accessCache[t]=0:se(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function Kl(){return zl().slots}function zl(e){const t=vo();return t.setupContext||(t.setupContext=_o(t))}function Ei(e){return G(e)?e.reduce((t,n)=>(t[n]=null,t),{}):e}let Vs=!0;function Wl(e){const t=Xr(e),n=e.proxy,s=e.ctx;Vs=!1,t.beforeCreate&&Si(t.beforeCreate,e,"bc");const{data:i,computed:r,methods:o,watch:a,provide:l,inject:u,created:c,beforeMount:p,mounted:g,beforeUpdate:v,updated:T,activated:C,deactivated:y,beforeDestroy:$,beforeUnmount:E,destroyed:N,unmounted:V,render:Z,renderTracked:fe,renderTriggered:re,errorCaptured:Ue,serverPrefetch:z,expose:D,inheritAttrs:q,components:et,directives:Le,filters:Pt}=t;if(u&&ql(u,s,null),o)for(const ee in o){const X=o[ee];W(X)&&(s[ee]=X.bind(n))}if(i){const ee=i.call(n,n);ie(ee)&&(e.data=Mn(ee))}if(Vs=!0,r)for(const ee in r){const X=r[ee],ze=W(X)?X.bind(n,n):W(X.get)?X.get.bind(n,n):ft,tt=!W(X)&&W(X.set)?X.set.bind(n):ft,je=_e({get:ze,set:tt});Object.defineProperty(s,ee,{enumerable:!0,configurable:!0,get:()=>je.value,set:Ae=>je.value=Ae})}if(a)for(const ee in a)Qr(a[ee],s,n,ee);if(l){const ee=W(l)?l.call(n):l;Reflect.ownKeys(ee).forEach(X=>{hn(X,ee[X])})}c&&Si(c,e,"c");function ge(ee,X){G(X)?X.forEach(ze=>ee(ze.bind(n))):X&&ee(X.bind(n))}if(ge(Ll,p),ge(os,g),ge(Dl,v),ge(Hl,T),ge(Ol,C),ge(Il,y),ge(Bl,Ue),ge(Fl,fe),ge($l,re),ge(ls,E),ge(zr,V),ge(Vl,z),G(D))if(D.length){const ee=e.exposed||(e.exposed={});D.forEach(X=>{Object.defineProperty(ee,X,{get:()=>n[X],set:ze=>n[X]=ze,enumerable:!0})})}else e.exposed||(e.exposed={});Z&&e.render===ft&&(e.render=Z),q!=null&&(e.inheritAttrs=q),et&&(e.components=et),Le&&(e.directives=Le),z&&jr(e)}function ql(e,t,n=ft){G(e)&&(e=$s(e));for(const s in e){const i=e[s];let r;ie(i)?"default"in i?r=Ye(i.from||s,i.default,!0):r=Ye(i.from||s):r=Ye(i),Te(r)?Object.defineProperty(t,s,{enumerable:!0,configurable:!0,get:()=>r.value,set:o=>r.value=o}):t[s]=r}}function Si(e,t,n){Xe(G(e)?e.map(s=>s.bind(t.proxy)):e.bind(t.proxy),t,n)}function Qr(e,t,n,s){let i=s.includes(".")?Ur(n,s):()=>n[s];if(ce(e)){const r=t[e];W(r)&&Yt(i,r)}else if(W(e))Yt(i,e.bind(n));else if(ie(e))if(G(e))e.forEach(r=>Qr(r,t,n,s));else{const r=W(e.handler)?e.handler.bind(n):t[e.handler];W(r)&&Yt(i,r,e)}}function Xr(e){const t=e.type,{mixins:n,extends:s}=t,{mixins:i,optionsCache:r,config:{optionMergeStrategies:o}}=e.appContext,a=r.get(t);let l;return a?l=a:!i.length&&!n&&!s?l=t:(l={},i.length&&i.forEach(u=>jn(l,u,o,!0)),jn(l,t,o)),ie(t)&&r.set(t,l),l}function jn(e,t,n,s=!1){const{mixins:i,extends:r}=t;r&&jn(e,r,n,!0),i&&i.forEach(o=>jn(e,o,n,!0));for(const o in t)if(!(s&&o==="expose")){const a=Yl[o]||n&&n[o];e[o]=a?a(e[o],t[o]):t[o]}return e}const Yl={data:Ci,props:Ai,emits:Ai,methods:an,computed:an,beforeCreate:Pe,created:Pe,beforeMount:Pe,mounted:Pe,beforeUpdate:Pe,updated:Pe,beforeDestroy:Pe,beforeUnmount:Pe,destroyed:Pe,unmounted:Pe,activated:Pe,deactivated:Pe,errorCaptured:Pe,serverPrefetch:Pe,components:an,directives:an,watch:Ql,provide:Ci,inject:Jl};function Ci(e,t){return t?e?function(){return Ce(W(e)?e.call(this,this):e,W(t)?t.call(this,this):t)}:t:e}function Jl(e,t){return an($s(e),$s(t))}function $s(e){if(G(e)){const t={};for(let n=0;nt==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${Ne(t)}Modifiers`]||e[`${Bt(t)}Modifiers`];function ta(e,t,...n){if(e.isUnmounted)return;const s=e.vnode.props||ae;let i=n;const r=t.startsWith("update:"),o=r&&ea(s,t.slice(7));o&&(o.trim&&(i=n.map(c=>ce(c)?c.trim():c)),o.number&&(i=n.map(es)));let a,l=s[a=hs(t)]||s[a=hs(Ne(t))];!l&&r&&(l=s[a=hs(Bt(t))]),l&&Xe(l,e,6,i);const u=s[a+"Once"];if(u){if(!e.emitted)e.emitted={};else if(e.emitted[a])return;e.emitted[a]=!0,Xe(u,e,6,i)}}const na=new WeakMap;function eo(e,t,n=!1){const s=n?na:t.emitsCache,i=s.get(e);if(i!==void 0)return i;const r=e.emits;let o={},a=!1;if(!W(e)){const l=u=>{const c=eo(u,t,!0);c&&(a=!0,Ce(o,c))};!n&&t.mixins.length&&t.mixins.forEach(l),e.extends&&l(e.extends),e.mixins&&e.mixins.forEach(l)}return!r&&!a?(ie(e)&&s.set(e,null),null):(G(r)?r.forEach(l=>o[l]=null):Ce(o,r),ie(e)&&s.set(e,o),o)}function us(e,t){return!e||!Yn(t)?!1:(t=t.slice(2).replace(/Once$/,""),se(e,t[0].toLowerCase()+t.slice(1))||se(e,Bt(t))||se(e,t))}function Ri(e){const{type:t,vnode:n,proxy:s,withProxy:i,propsOptions:[r],slots:o,attrs:a,emit:l,render:u,renderCache:c,props:p,data:g,setupState:v,ctx:T,inheritAttrs:C}=e,y=Bn(e);let $,E;try{if(n.shapeFlag&4){const V=i||s,Z=V;$=ut(u.call(Z,V,c,p,v,g,T)),E=a}else{const V=t;$=ut(V.length>1?V(p,{attrs:a,slots:o,emit:l}):V(p,null)),E=t.props?a:sa(a)}}catch(V){vn.length=0,ss(V,e,1),$=L(xt)}let N=$;if(E&&C!==!1){const V=Object.keys(E),{shapeFlag:Z}=N;V.length&&Z&7&&(r&&V.some(Jn)&&(E=ia(E,r)),N=en(N,E,!1,!0))}return n.dirs&&(N=en(N,null,!1,!0),N.dirs=N.dirs?N.dirs.concat(n.dirs):n.dirs),n.transition&&oi(N,n.transition),$=N,Bn(y),$}const sa=e=>{let t;for(const n in e)(n==="class"||n==="style"||Yn(n))&&((t||(t={}))[n]=e[n]);return t},ia=(e,t)=>{const n={};for(const s in e)(!Jn(s)||!(s.slice(9)in t))&&(n[s]=e[s]);return n};function ra(e,t,n){const{props:s,children:i,component:r}=e,{props:o,children:a,patchFlag:l}=t,u=r.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&l>=0){if(l&1024)return!0;if(l&16)return s?Mi(s,o,u):!!o;if(l&8){const c=t.dynamicProps;for(let p=0;pObject.create(no),io=e=>Object.getPrototypeOf(e)===no;function la(e,t,n,s=!1){const i={},r=so();e.propsDefaults=Object.create(null),ro(e,t,i,r);for(const o in e.propsOptions[0])o in i||(i[o]=void 0);n?e.props=s?i:Nr(i):e.type.props?e.props=i:e.props=r,e.attrs=r}function aa(e,t,n,s){const{props:i,attrs:r,vnode:{patchFlag:o}}=e,a=ne(i),[l]=e.propsOptions;let u=!1;if((s||o>0)&&!(o&16)){if(o&8){const c=e.vnode.dynamicProps;for(let p=0;p{l=!0;const[g,v]=oo(p,t,!0);Ce(o,g),v&&a.push(...v)};!n&&t.mixins.length&&t.mixins.forEach(c),e.extends&&c(e.extends),e.mixins&&e.mixins.forEach(c)}if(!r&&!l)return ie(e)&&s.set(e,zt),zt;if(G(r))for(let c=0;ce==="_"||e==="_ctx"||e==="$stable",ai=e=>G(e)?e.map(ut):[ut(e)],ca=(e,t,n)=>{if(t._n)return t;const s=be((...i)=>ai(t(...i)),n);return s._c=!1,s},lo=(e,t,n)=>{const s=e._ctx;for(const i in e){if(li(i))continue;const r=e[i];if(W(r))t[i]=ca(i,r,s);else if(r!=null){const o=ai(r);t[i]=()=>o}}},ao=(e,t)=>{const n=ai(t);e.slots.default=()=>n},uo=(e,t,n)=>{for(const s in t)(n||!li(s))&&(e[s]=t[s])},fa=(e,t,n)=>{const s=e.slots=so();if(e.vnode.shapeFlag&32){const i=t._;i?(uo(s,t,n),n&&gr(s,"_",i,!0)):lo(t,s)}else t&&ao(e,t)},da=(e,t,n)=>{const{vnode:s,slots:i}=e;let r=!0,o=ae;if(s.shapeFlag&32){const a=t._;a?n&&a===1?r=!1:uo(i,t,n):(r=!t.$stable,lo(t,i)),o=t}else t&&(ao(e,t),o={default:1});if(r)for(const a in i)!li(a)&&o[a]==null&&delete i[a]},De=va;function pa(e){return ha(e)}function ha(e,t){const n=ts();n.__VUE__=!0;const{insert:s,remove:i,patchProp:r,createElement:o,createText:a,createComment:l,setText:u,setElementText:c,parentNode:p,nextSibling:g,setScopeId:v=ft,insertStaticContent:T}=e,C=(f,d,m,_=null,x=null,b=null,P=void 0,M=null,R=!!d.dynamicChildren)=>{if(f===d)return;f&&!on(f,d)&&(_=w(f),Ae(f,x,b,!0),f=null),d.patchFlag===-2&&(R=!1,d.dynamicChildren=null);const{type:S,ref:U,shapeFlag:I}=d;switch(S){case cs:y(f,d,m,_);break;case xt:$(f,d,m,_);break;case Hn:f==null&&E(d,m,_,P);break;case j:et(f,d,m,_,x,b,P,M,R);break;default:I&1?Z(f,d,m,_,x,b,P,M,R):I&6?Le(f,d,m,_,x,b,P,M,R):(I&64||I&128)&&S.process(f,d,m,_,x,b,P,M,R,F)}U!=null&&x?mn(U,f&&f.ref,b,d||f,!d):U==null&&f&&f.ref!=null&&mn(f.ref,null,b,f,!0)},y=(f,d,m,_)=>{if(f==null)s(d.el=a(d.children),m,_);else{const x=d.el=f.el;d.children!==f.children&&u(x,d.children)}},$=(f,d,m,_)=>{f==null?s(d.el=l(d.children||""),m,_):d.el=f.el},E=(f,d,m,_)=>{[f.el,f.anchor]=T(f.children,d,m,_,f.el,f.anchor)},N=({el:f,anchor:d},m,_)=>{let x;for(;f&&f!==d;)x=g(f),s(f,m,_),f=x;s(d,m,_)},V=({el:f,anchor:d})=>{let m;for(;f&&f!==d;)m=g(f),i(f),f=m;i(d)},Z=(f,d,m,_,x,b,P,M,R)=>{if(d.type==="svg"?P="svg":d.type==="math"&&(P="mathml"),f==null)fe(d,m,_,x,b,P,M,R);else{const S=f.el&&f.el._isVueCE?f.el:null;try{S&&S._beginPatch(),z(f,d,x,b,P,M,R)}finally{S&&S._endPatch()}}},fe=(f,d,m,_,x,b,P,M)=>{let R,S;const{props:U,shapeFlag:I,transition:B,dirs:K}=f;if(R=f.el=o(f.type,b,U&&U.is,U),I&8?c(R,f.children):I&16&&Ue(f.children,R,null,_,x,ws(f,b),P,M),K&&Ot(f,null,_,"created"),re(R,f,f.scopeId,P,_),U){for(const le in U)le!=="value"&&!fn(le)&&r(R,le,null,U[le],b,_);"value"in U&&r(R,"value",null,U.value,b),(S=U.onVnodeBeforeMount)&&rt(S,_,f)}K&&Ot(f,null,_,"beforeMount");const Q=ma(x,B);Q&&B.beforeEnter(R),s(R,d,m),((S=U&&U.onVnodeMounted)||Q||K)&&De(()=>{try{S&&rt(S,_,f),Q&&B.enter(R),K&&Ot(f,null,_,"mounted")}finally{}},x)},re=(f,d,m,_,x)=>{if(m&&v(f,m),_)for(let b=0;b<_.length;b++)v(f,_[b]);if(x){let b=x.subTree;if(d===b||ho(b.type)&&(b.ssContent===d||b.ssFallback===d)){const P=x.vnode;re(f,P,P.scopeId,P.slotScopeIds,x.parent)}}},Ue=(f,d,m,_,x,b,P,M,R=0)=>{for(let S=R;S{const M=d.el=f.el;let{patchFlag:R,dynamicChildren:S,dirs:U}=d;R|=f.patchFlag&16;const I=f.props||ae,B=d.props||ae;let K;if(m&&It(m,!1),(K=B.onVnodeBeforeUpdate)&&rt(K,m,d,f),U&&Ot(d,f,m,"beforeUpdate"),m&&It(m,!0),(I.innerHTML&&B.innerHTML==null||I.textContent&&B.textContent==null)&&c(M,""),S?D(f.dynamicChildren,S,M,m,_,ws(d,x),b):P||X(f,d,M,null,m,_,ws(d,x),b,!1),R>0){if(R&16)q(M,I,B,m,x);else if(R&2&&I.class!==B.class&&r(M,"class",null,B.class,x),R&4&&r(M,"style",I.style,B.style,x),R&8){const Q=d.dynamicProps;for(let le=0;le{K&&rt(K,m,d,f),U&&Ot(d,f,m,"updated")},_)},D=(f,d,m,_,x,b,P)=>{for(let M=0;M{if(d!==m){if(d!==ae)for(const b in d)!fn(b)&&!(b in m)&&r(f,b,d[b],null,x,_);for(const b in m){if(fn(b))continue;const P=m[b],M=d[b];P!==M&&b!=="value"&&r(f,b,M,P,x,_)}"value"in m&&r(f,"value",d.value,m.value,x)}},et=(f,d,m,_,x,b,P,M,R)=>{const S=d.el=f?f.el:a(""),U=d.anchor=f?f.anchor:a("");let{patchFlag:I,dynamicChildren:B,slotScopeIds:K}=d;K&&(M=M?M.concat(K):K),f==null?(s(S,m,_),s(U,m,_),Ue(d.children||[],m,U,x,b,P,M,R)):I>0&&I&64&&B&&f.dynamicChildren&&f.dynamicChildren.length===B.length?(D(f.dynamicChildren,B,m,x,b,P,M),(d.key!=null||x&&d===x.subTree)&&co(f,d,!0)):X(f,d,m,U,x,b,P,M,R)},Le=(f,d,m,_,x,b,P,M,R)=>{d.slotScopeIds=M,f==null?d.shapeFlag&512?x.ctx.activate(d,m,_,P,R):Pt(d,m,_,x,b,P,R):St(f,d,R)},Pt=(f,d,m,_,x,b,P)=>{const M=f.component=Sa(f,_,x);if(Gr(f)&&(M.ctx.renderer=F),Ca(M,!1,P),M.asyncDep){if(x&&x.registerDep(M,ge,P),!f.el){const R=M.subTree=L(xt);$(null,R,d,m),f.placeholder=R.el}}else ge(M,f,d,m,x,b,P)},St=(f,d,m)=>{const _=d.component=f.component;if(ra(f,d,m))if(_.asyncDep&&!_.asyncResolved){ee(_,d,m);return}else _.next=d,_.update();else d.el=f.el,_.vnode=d},ge=(f,d,m,_,x,b,P)=>{const M=()=>{if(f.isMounted){let{next:I,bu:B,u:K,parent:Q,vnode:le}=f;{const st=fo(f);if(st){I&&(I.el=le.el,ee(f,I,P)),st.asyncDep.then(()=>{De(()=>{f.isUnmounted||S()},x)});return}}let oe=I,ve;It(f,!1),I?(I.el=le.el,ee(f,I,P)):I=le,B&&Dn(B),(ve=I.props&&I.props.onVnodeBeforeUpdate)&&rt(ve,Q,I,le),It(f,!0);const xe=Ri(f),nt=f.subTree;f.subTree=xe,C(nt,xe,p(nt.el),w(nt),f,x,b),I.el=xe.el,oe===null&&oa(f,xe.el),K&&De(K,x),(ve=I.props&&I.props.onVnodeUpdated)&&De(()=>rt(ve,Q,I,le),x)}else{let I;const{el:B,props:K}=d,{bm:Q,m:le,parent:oe,root:ve,type:xe}=f,nt=Jt(d);It(f,!1),Q&&Dn(Q),!nt&&(I=K&&K.onVnodeBeforeMount)&&rt(I,oe,d),It(f,!0);{ve.ce&&ve.ce._hasShadowRoot()&&ve.ce._injectChildStyle(xe,f.parent?f.parent.type:void 0);const st=f.subTree=Ri(f);C(null,st,m,_,f,x,b),d.el=st.el}if(le&&De(le,x),!nt&&(I=K&&K.onVnodeMounted)){const st=d;De(()=>rt(I,oe,st),x)}(d.shapeFlag&256||oe&&Jt(oe.vnode)&&oe.vnode.shapeFlag&256)&&f.a&&De(f.a,x),f.isMounted=!0,d=m=_=null}};f.scope.on();const R=f.effect=new _r(M);f.scope.off();const S=f.update=R.run.bind(R),U=f.job=R.runIfDirty.bind(R);U.i=f,U.id=f.uid,R.scheduler=()=>ii(U),It(f,!0),S()},ee=(f,d,m)=>{d.component=f;const _=f.vnode.props;f.vnode=d,f.next=null,aa(f,d.props,_,m),da(f,d.children,m),bt(),bi(f),_t()},X=(f,d,m,_,x,b,P,M,R=!1)=>{const S=f&&f.children,U=f?f.shapeFlag:0,I=d.children,{patchFlag:B,shapeFlag:K}=d;if(B>0){if(B&128){tt(S,I,m,_,x,b,P,M,R);return}else if(B&256){ze(S,I,m,_,x,b,P,M,R);return}}K&8?(U&16&&Ge(S,x,b),I!==S&&c(m,I)):U&16?K&16?tt(S,I,m,_,x,b,P,M,R):Ge(S,x,b,!0):(U&8&&c(m,""),K&16&&Ue(I,m,_,x,b,P,M,R))},ze=(f,d,m,_,x,b,P,M,R)=>{f=f||zt,d=d||zt;const S=f.length,U=d.length,I=Math.min(S,U);let B;for(B=0;BU?Ge(f,x,b,!0,!1,I):Ue(d,m,_,x,b,P,M,R,I)},tt=(f,d,m,_,x,b,P,M,R)=>{let S=0;const U=d.length;let I=f.length-1,B=U-1;for(;S<=I&&S<=B;){const K=f[S],Q=d[S]=R?gt(d[S]):ut(d[S]);if(on(K,Q))C(K,Q,m,null,x,b,P,M,R);else break;S++}for(;S<=I&&S<=B;){const K=f[I],Q=d[B]=R?gt(d[B]):ut(d[B]);if(on(K,Q))C(K,Q,m,null,x,b,P,M,R);else break;I--,B--}if(S>I){if(S<=B){const K=B+1,Q=KB)for(;S<=I;)Ae(f[S],x,b,!0),S++;else{const K=S,Q=S,le=new Map;for(S=Q;S<=B;S++){const Ve=d[S]=R?gt(d[S]):ut(d[S]);Ve.key!=null&&le.set(Ve.key,S)}let oe,ve=0;const xe=B-Q+1;let nt=!1,st=0;const sn=new Array(xe);for(S=0;S=xe){Ae(Ve,x,b,!0);continue}let it;if(Ve.key!=null)it=le.get(Ve.key);else for(oe=Q;oe<=B;oe++)if(sn[oe-Q]===0&&on(Ve,d[oe])){it=oe;break}it===void 0?Ae(Ve,x,b,!0):(sn[it-Q]=S+1,it>=st?st=it:nt=!0,C(Ve,d[it],m,null,x,b,P,M,R),ve++)}const di=nt?ga(sn):zt;for(oe=di.length-1,S=xe-1;S>=0;S--){const Ve=Q+S,it=d[Ve],pi=d[Ve+1],hi=Ve+1{const{el:b,type:P,transition:M,children:R,shapeFlag:S}=f;if(S&6){je(f.component.subTree,d,m,_);return}if(S&128){f.suspense.move(d,m,_);return}if(S&64){P.move(f,d,m,F);return}if(P===j){s(b,d,m);for(let I=0;IM.enter(b),x));else{const{leave:I,delayLeave:B,afterLeave:K}=M,Q=()=>{f.ctx.isUnmounted?i(b):s(b,d,m)},le=()=>{const oe=b._isLeaving||!!b[bs];b._isLeaving&&b[bs](!0),M.persisted&&!oe?Q():I(b,()=>{Q(),K&&K()})};B?B(b,Q,le):le()}else s(b,d,m)},Ae=(f,d,m,_=!1,x=!1)=>{const{type:b,props:P,ref:M,children:R,dynamicChildren:S,shapeFlag:U,patchFlag:I,dirs:B,cacheIndex:K,memo:Q}=f;if(I===-2&&(x=!1),M!=null&&(bt(),mn(M,null,m,f,!0),_t()),K!=null&&(d.renderCache[K]=void 0),U&256){d.ctx.deactivate(f);return}const le=U&1&&B,oe=!Jt(f);let ve;if(oe&&(ve=P&&P.onVnodeBeforeUnmount)&&rt(ve,d,f),U&6)kt(f.component,m,_);else{if(U&128){f.suspense.unmount(m,_);return}le&&Ot(f,null,d,"beforeUnmount"),U&64?f.type.remove(f,d,m,F,_):S&&!S.hasOnce&&(b!==j||I>0&&I&64)?Ge(S,d,m,!1,!0):(b===j&&I&384||!x&&U&16)&&Ge(R,d,m),_&&Ct(f)}const xe=Q!=null&&K==null;(oe&&(ve=P&&P.onVnodeUnmounted)||le||xe)&&De(()=>{ve&&rt(ve,d,f),le&&Ot(f,null,d,"unmounted"),xe&&(f.el=null)},m)},Ct=f=>{const{type:d,el:m,anchor:_,transition:x}=f;if(d===j){At(m,_);return}if(d===Hn){V(f);return}const b=()=>{i(m),x&&!x.persisted&&x.afterLeave&&x.afterLeave()};if(f.shapeFlag&1&&x&&!x.persisted){const{leave:P,delayLeave:M}=x,R=()=>P(m,b);M?M(f.el,b,R):R()}else b()},At=(f,d)=>{let m;for(;f!==d;)m=g(f),i(f),f=m;i(d)},kt=(f,d,m)=>{const{bum:_,scope:x,job:b,subTree:P,um:M,m:R,a:S}=f;Pi(R),Pi(S),_&&Dn(_),x.stop(),b&&(b.flags|=8,Ae(P,f,d,m)),M&&De(M,d),De(()=>{f.isUnmounted=!0},d)},Ge=(f,d,m,_=!1,x=!1,b=0)=>{for(let P=b;P{if(f.shapeFlag&6)return w(f.component.subTree);if(f.shapeFlag&128)return f.suspense.next();const d=g(f.anchor||f.el),m=d&&d[Pl];return m?g(m):d};let H=!1;const O=(f,d,m)=>{let _;f==null?d._vnode&&(Ae(d._vnode,null,null,!0),_=d._vnode.component):C(d._vnode||null,f,d,null,null,null,m),d._vnode=f,H||(H=!0,bi(_),$r(),H=!1)},F={p:C,um:Ae,m:je,r:Ct,mt:Pt,mc:Ue,pc:X,pbc:D,n:w,o:e};return{render:O,hydrate:void 0,createApp:Zl(O)}}function ws({type:e,props:t},n){return n==="svg"&&e==="foreignObject"||n==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function It({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function ma(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function co(e,t,n=!1){const s=e.children,i=t.children;if(G(s)&&G(i))for(let r=0;r>1,e[n[a]]0&&(t[s]=n[r-1]),n[r]=s)}}for(r=n.length,o=n[r-1];r-- >0;)n[r]=o,o=t[o];return n}function fo(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:fo(t)}function Pi(e){if(e)for(let t=0;te.__isSuspense;function va(e,t){t&&t.pendingBranch?G(e)?t.effects.push(...e):t.effects.push(e):Cl(e)}const j=Symbol.for("v-fgt"),cs=Symbol.for("v-txt"),xt=Symbol.for("v-cmt"),Hn=Symbol.for("v-stc"),vn=[];let Fe=null;function A(e=!1){vn.push(Fe=e?null:[])}function ya(){vn.pop(),Fe=vn[vn.length-1]||null}let xn=1;function Gn(e,t=!1){xn+=e,e<0&&Fe&&t&&(Fe.hasOnce=!0)}function mo(e){return e.dynamicChildren=xn>0?Fe||zt:null,ya(),xn>0&&Fe&&Fe.push(e),e}function k(e,t,n,s,i,r){return mo(h(e,t,n,s,i,r,!0))}function We(e,t,n,s,i){return mo(L(e,t,n,s,i,!0))}function En(e){return e?e.__v_isVNode===!0:!1}function on(e,t){return e.type===t.type&&e.key===t.key}const go=({key:e})=>e??null,Vn=({ref:e,ref_key:t,ref_for:n})=>(typeof e=="number"&&(e=""+e),e!=null?ce(e)||Te(e)||W(e)?{i:Se,r:e,k:t,f:!!n}:e:null);function h(e,t=null,n=null,s=0,i=null,r=e===j?0:1,o=!1,a=!1){const l={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&go(t),ref:t&&Vn(t),scopeId:Br,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:r,patchFlag:s,dynamicProps:i,dynamicChildren:null,appContext:null,ctx:Se};return a?(ui(l,n),r&128&&e.normalize(l)):n&&(l.shapeFlag|=ce(n)?8:16),xn>0&&!o&&Fe&&(l.patchFlag>0||r&6)&&l.patchFlag!==32&&Fe.push(l),l}const L=ba;function ba(e,t=null,n=null,s=0,i=null,r=!1){if((!e||e===qr)&&(e=xt),En(e)){const a=en(e,t,!0);return n&&ui(a,n),xn>0&&!r&&Fe&&(a.shapeFlag&6?Fe[Fe.indexOf(e)]=a:Fe.push(a)),a.patchFlag=-2,a}if(Ta(e)&&(e=e.__vccOpts),t){t=_a(t);let{class:a,style:l}=t;a&&!ce(a)&&(t.class=Be(a)),ie(l)&&(si(l)&&!G(l)&&(l=Ce({},l)),t.style=dt(l))}const o=ce(e)?1:ho(e)?128:kl(e)?64:ie(e)?4:W(e)?2:0;return h(e,t,n,s,i,o,r,!0)}function _a(e){return e?si(e)||io(e)?Ce({},e):e:null}function en(e,t,n=!1,s=!1){const{props:i,ref:r,patchFlag:o,children:a,transition:l}=e,u=t?wa(i||{},t):i,c={__v_isVNode:!0,__v_skip:!0,type:e.type,props:u,key:u&&go(u),ref:t&&t.ref?n&&r?G(r)?r.concat(Vn(t)):[r,Vn(t)]:Vn(t):r,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:a,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==j?o===-1?16:o|16:o,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:l,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&en(e.ssContent),ssFallback:e.ssFallback&&en(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return l&&s&&oi(c,l.clone(c)),c}function pe(e=" ",t=0){return L(cs,null,e,t)}function Lt(e,t){const n=L(Hn,null,e);return n.staticCount=t,n}function He(e="",t=!1){return t?(A(),We(xt,null,e)):L(xt,null,e)}function ut(e){return e==null||typeof e=="boolean"?L(xt):G(e)?L(j,null,e.slice()):En(e)?gt(e):L(cs,null,String(e))}function gt(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:en(e)}function ui(e,t){let n=0;const{shapeFlag:s}=e;if(t==null)t=null;else if(G(t))n=16;else if(typeof t=="object")if(s&65){const i=t.default;i&&(i._c&&(i._d=!1),ui(e,i()),i._c&&(i._d=!0));return}else{n=32;const i=t._;!i&&!io(t)?t._ctx=Se:i===3&&Se&&(Se.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else W(t)?(t={default:t,_ctx:Se},n=32):(t=String(t),s&64?(n=16,t=[pe(t)]):n=8);e.children=t,e.shapeFlag|=n}function wa(...e){const t={};for(let n=0;nMe||Se;let Kn,Bs;{const e=ts(),t=(n,s)=>{let i;return(i=e[n])||(i=e[n]=[]),i.push(s),r=>{i.length>1?i.forEach(o=>o(r)):i[0](r)}};Kn=t("__VUE_INSTANCE_SETTERS__",n=>Me=n),Bs=t("__VUE_SSR_SETTERS__",n=>Sn=n)}const Pn=e=>{const t=Me;return Kn(e),e.scope.on(),()=>{e.scope.off(),Kn(t)}},ki=()=>{Me&&Me.scope.off(),Kn(null)};function yo(e){return e.vnode.shapeFlag&4}let Sn=!1;function Ca(e,t=!1,n=!1){t&&Bs(t);const{props:s,children:i}=e.vnode,r=yo(e);la(e,s,r,t),fa(e,i,n||t);const o=r?Aa(e,t):void 0;return t&&Bs(!1),o}function Aa(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,Gl);const{setup:s}=n;if(s){bt();const i=e.setupContext=s.length>1?_o(e):null,r=Pn(e),o=Tn(s,e,0,[e.props,i]),a=pr(o);if(_t(),r(),(a||e.sp)&&!Jt(e)&&jr(e),a){if(o.then(ki,ki),t)return o.then(l=>{Oi(e,l)}).catch(l=>{ss(l,e,0)});e.asyncDep=o}else Oi(e,o)}else bo(e)}function Oi(e,t,n){W(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:ie(t)&&(e.setupState=Dr(t)),bo(e)}function bo(e,t,n){const s=e.type;e.render||(e.render=s.render||ft);{const i=Pn(e);bt();try{Wl(e)}finally{_t(),i()}}}const Ra={get(e,t){return Re(e,"get",""),e[t]}};function _o(e){const t=n=>{e.exposed=n||{}};return{attrs:new Proxy(e.attrs,Ra),slots:e.slots,emit:e.emit,expose:t}}function fs(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(Dr(ml(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in gn)return gn[n](e)},has(t,n){return n in t||n in gn}})):e.proxy}function Ma(e,t=!0){return W(e)?e.displayName||e.name:e.name||t&&e.__name}function Ta(e){return W(e)&&"__vccOpts"in e}const _e=(e,t)=>_l(e,t,Sn);function wo(e,t,n){try{Gn(-1);const s=arguments.length;return s===2?ie(t)&&!G(t)?En(t)?L(e,null,[t]):L(e,t):L(e,null,t):(s>3?n=Array.prototype.slice.call(arguments,2):s===3&&En(n)&&(n=[n]),L(e,t,n))}finally{Gn(1)}}const Pa="3.5.35";/**
+* @vue/runtime-dom v3.5.35
+* (c) 2018-present Yuxi (Evan) You and Vue contributors
+* @license MIT
+**/let Us;const Ii=typeof window<"u"&&window.trustedTypes;if(Ii)try{Us=Ii.createPolicy("vue",{createHTML:e=>e})}catch{}const xo=Us?e=>Us.createHTML(e):e=>e,ka="http://www.w3.org/2000/svg",Oa="http://www.w3.org/1998/Math/MathML",mt=typeof document<"u"?document:null,Ni=mt&&mt.createElement("template"),Ia={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,s)=>{const i=t==="svg"?mt.createElementNS(ka,e):t==="mathml"?mt.createElementNS(Oa,e):n?mt.createElement(e,{is:n}):mt.createElement(e);return e==="select"&&s&&s.multiple!=null&&i.setAttribute("multiple",s.multiple),i},createText:e=>mt.createTextNode(e),createComment:e=>mt.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>mt.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,s,i,r){const o=n?n.previousSibling:t.lastChild;if(i&&(i===r||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),n),!(i===r||!(i=i.nextSibling)););else{Ni.innerHTML=xo(s==="svg"?`${e} `:s==="mathml"?`${e} `:e);const a=Ni.content;if(s==="svg"||s==="mathml"){const l=a.firstChild;for(;l.firstChild;)a.appendChild(l.firstChild);a.removeChild(l)}t.insertBefore(a,n)}return[o?o.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},Na=Symbol("_vtc");function La(e,t,n){const s=e[Na];s&&(t=(t?[t,...s]:[...s]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}const Li=Symbol("_vod"),Da=Symbol("_vsh"),Ha=Symbol(""),Va=/(?:^|;)\s*display\s*:/;function $a(e,t,n){const s=e.style,i=ce(n);let r=!1;if(n&&!i){if(t)if(ce(t))for(const o of t.split(";")){const a=o.slice(0,o.indexOf(":")).trim();n[a]==null&&un(s,a,"")}else for(const o in t)n[o]==null&&un(s,o,"");for(const o in n){o==="display"&&(r=!0);const a=n[o];a!=null?Ba(e,o,!ce(t)&&t?t[o]:void 0,a)||un(s,o,a):un(s,o,"")}}else if(i){if(t!==n){const o=s[Ha];o&&(n+=";"+o),s.cssText=n,r=Va.test(n)}}else t&&e.removeAttribute("style");Li in e&&(e[Li]=r?s.display:"",e[Da]&&(s.display="none"))}const Di=/\s*!important$/;function un(e,t,n){if(G(n))n.forEach(s=>un(e,t,s));else if(n==null&&(n=""),t.startsWith("--"))e.setProperty(t,n);else{const s=Fa(e,t);Di.test(n)?e.setProperty(Bt(s),n.replace(Di,""),"important"):e[s]=n}}const Hi=["Webkit","Moz","ms"],xs={};function Fa(e,t){const n=xs[t];if(n)return n;let s=Ne(t);if(s!=="filter"&&s in e)return xs[t]=s;s=Zn(s);for(let i=0;iEs||(Ka.then(()=>Es=0),Es=Date.now());function Wa(e,t){const n=s=>{if(!s._vts)s._vts=Date.now();else if(s._vts<=n.attached)return;const i=n.value;if(G(i)){const r=s.stopImmediatePropagation;s.stopImmediatePropagation=()=>{r.call(s),s._stopped=!0};const o=i.slice(),a=[s];for(let l=0;le.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,qa=(e,t,n,s,i,r)=>{const o=i==="svg";t==="class"?La(e,s,o):t==="style"?$a(e,n,s):Yn(t)?Jn(t)||ja(e,t,n,s,r):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Ya(e,t,s,o))?(Fi(e,t,s),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&$i(e,t,s,o,r,t!=="value")):e._isVueCE&&(Ja(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!ce(s)))?Fi(e,Ne(t),s,r,t):(t==="true-value"?e._trueValue=s:t==="false-value"&&(e._falseValue=s),$i(e,t,s,o))};function Ya(e,t,n,s){if(s)return!!(t==="innerHTML"||t==="textContent"||t in e&&ji(t)&&W(n));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const i=e.tagName;if(i==="IMG"||i==="VIDEO"||i==="CANVAS"||i==="SOURCE")return!1}return ji(t)&&ce(n)?!1:t in e}function Ja(e,t){const n=e._def.props;if(!n)return!1;const s=Ne(t);return Array.isArray(n)?n.some(i=>Ne(i)===s):Object.keys(n).some(i=>Ne(i)===s)}const zn=e=>{const t=e.props["onUpdate:modelValue"]||!1;return G(t)?n=>Dn(t,n):t};function Qa(e){e.target.composing=!0}function Gi(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const Xt=Symbol("_assign");function Ki(e,t,n){return t&&(e=e.trim()),n&&(e=es(e)),e}const Md={created(e,{modifiers:{lazy:t,trim:n,number:s}},i){e[Xt]=zn(i);const r=s||i.props&&i.props.type==="number";Dt(e,t?"change":"input",o=>{o.target.composing||e[Xt](Ki(e.value,n,r))}),(n||r)&&Dt(e,"change",()=>{e.value=Ki(e.value,n,r)}),t||(Dt(e,"compositionstart",Qa),Dt(e,"compositionend",Gi),Dt(e,"change",Gi))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:s,trim:i,number:r}},o){if(e[Xt]=zn(o),e.composing)return;const a=(r||e.type==="number")&&!/^0\d/.test(e.value)?es(e.value):e.value,l=t??"";if(a===l)return;const u=e.getRootNode();(u instanceof Document||u instanceof ShadowRoot)&&u.activeElement===e&&e.type!=="range"&&(s&&t===n||i&&e.value.trim()===l)||(e.value=l)}},Xa={deep:!0,created(e,{value:t,modifiers:{number:n}},s){const i=Qn(t);Dt(e,"change",()=>{const r=Array.prototype.filter.call(e.options,o=>o.selected).map(o=>n?es(Wn(o)):Wn(o));e[Xt](e.multiple?i?new Set(r):r:r[0]),e._assigning=!0,is(()=>{e._assigning=!1})}),e[Xt]=zn(s)},mounted(e,{value:t}){zi(e,t)},beforeUpdate(e,t,n){e[Xt]=zn(n)},updated(e,{value:t}){e._assigning||zi(e,t)}};function zi(e,t){const n=e.multiple,s=G(t);if(!(n&&!s&&!Qn(t))){for(let i=0,r=e.options.length;iString(u)===String(a)):o.selected=Yo(t,a)>-1}else o.selected=t.has(a);else if(Rn(Wn(o),t)){e.selectedIndex!==i&&(e.selectedIndex=i);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Wn(e){return"_value"in e?e._value:e.value}const Za=["ctrl","shift","alt","meta"],eu={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>Za.some(n=>e[`${n}Key`]&&!t.includes(n))},Eo=(e,t)=>{if(!e)return e;const n=e._withMods||(e._withMods={}),s=t.join(".");return n[s]||(n[s]=((i,...r)=>{for(let o=0;o{const t=nu().createApp(...e),{mount:n}=t;return t.mount=s=>{const i=ru(s);if(!i)return;const r=t._component;!W(r)&&!r.render&&!r.template&&(r.template=i.innerHTML),i.nodeType===1&&(i.textContent="");const o=n(i,!1,iu(i));return i instanceof Element&&(i.removeAttribute("v-cloak"),i.setAttribute("data-v-app","")),o},t});function iu(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function ru(e){return ce(e)?document.querySelector(e):e}/*!
+ * vue-router v4.6.4
+ * (c) 2025 Eduardo San Martin Morote
+ * @license MIT
+ */const Kt=typeof document<"u";function So(e){return typeof e=="object"||"displayName"in e||"props"in e||"__vccOpts"in e}function ou(e){return e.__esModule||e[Symbol.toStringTag]==="Module"||e.default&&So(e.default)}const te=Object.assign;function Ss(e,t){const n={};for(const s in t){const i=t[s];n[s]=Ze(i)?i.map(e):e(i)}return n}const yn=()=>{},Ze=Array.isArray;function qi(e,t){const n={};for(const s in e)n[s]=s in t?t[s]:e[s];return n}const Co=/#/g,lu=/&/g,au=/\//g,uu=/=/g,cu=/\?/g,Ao=/\+/g,fu=/%5B/g,du=/%5D/g,Ro=/%5E/g,pu=/%60/g,Mo=/%7B/g,hu=/%7C/g,To=/%7D/g,mu=/%20/g;function ci(e){return e==null?"":encodeURI(""+e).replace(hu,"|").replace(fu,"[").replace(du,"]")}function gu(e){return ci(e).replace(Mo,"{").replace(To,"}").replace(Ro,"^")}function js(e){return ci(e).replace(Ao,"%2B").replace(mu,"+").replace(Co,"%23").replace(lu,"%26").replace(pu,"`").replace(Mo,"{").replace(To,"}").replace(Ro,"^")}function vu(e){return js(e).replace(uu,"%3D")}function yu(e){return ci(e).replace(Co,"%23").replace(cu,"%3F")}function bu(e){return yu(e).replace(au,"%2F")}function Cn(e){if(e==null)return null;try{return decodeURIComponent(""+e)}catch{}return""+e}const _u=/\/$/,wu=e=>e.replace(_u,"");function Cs(e,t,n="/"){let s,i={},r="",o="";const a=t.indexOf("#");let l=t.indexOf("?");return l=a>=0&&l>a?-1:l,l>=0&&(s=t.slice(0,l),r=t.slice(l,a>0?a:t.length),i=e(r.slice(1))),a>=0&&(s=s||t.slice(0,a),o=t.slice(a,t.length)),s=Cu(s??t,n),{fullPath:s+r+o,path:s,query:i,hash:Cn(o)}}function xu(e,t){const n=t.query?e(t.query):"";return t.path+(n&&"?")+n+(t.hash||"")}function Yi(e,t){return!t||!e.toLowerCase().startsWith(t.toLowerCase())?e:e.slice(t.length)||"/"}function Eu(e,t,n){const s=t.matched.length-1,i=n.matched.length-1;return s>-1&&s===i&&tn(t.matched[s],n.matched[i])&&Po(t.params,n.params)&&e(t.query)===e(n.query)&&t.hash===n.hash}function tn(e,t){return(e.aliasOf||e)===(t.aliasOf||t)}function Po(e,t){if(Object.keys(e).length!==Object.keys(t).length)return!1;for(var n in e)if(!Su(e[n],t[n]))return!1;return!0}function Su(e,t){return Ze(e)?Ji(e,t):Ze(t)?Ji(t,e):(e==null?void 0:e.valueOf())===(t==null?void 0:t.valueOf())}function Ji(e,t){return Ze(t)?e.length===t.length&&e.every((n,s)=>n===t[s]):e.length===1&&e[0]===t}function Cu(e,t){if(e.startsWith("/"))return e;if(!e)return t;const n=t.split("/"),s=e.split("/"),i=s[s.length-1];(i===".."||i===".")&&s.push("");let r=n.length-1,o,a;for(o=0;o1&&r--;else break;return n.slice(0,r).join("/")+"/"+s.slice(o).join("/")}const Rt={path:"/",name:void 0,params:{},query:{},hash:"",fullPath:"/",matched:[],meta:{},redirectedFrom:void 0};let Gs=(function(e){return e.pop="pop",e.push="push",e})({}),As=(function(e){return e.back="back",e.forward="forward",e.unknown="",e})({});function Au(e){if(!e)if(Kt){const t=document.querySelector("base");e=t&&t.getAttribute("href")||"/",e=e.replace(/^\w+:\/\/[^\/]+/,"")}else e="/";return e[0]!=="/"&&e[0]!=="#"&&(e="/"+e),wu(e)}const Ru=/^[^#]+#/;function Mu(e,t){return e.replace(Ru,"#")+t}function Tu(e,t){const n=document.documentElement.getBoundingClientRect(),s=e.getBoundingClientRect();return{behavior:t.behavior,left:s.left-n.left-(t.left||0),top:s.top-n.top-(t.top||0)}}const ds=()=>({left:window.scrollX,top:window.scrollY});function Pu(e){let t;if("el"in e){const n=e.el,s=typeof n=="string"&&n.startsWith("#"),i=typeof n=="string"?s?document.getElementById(n.slice(1)):document.querySelector(n):n;if(!i)return;t=Tu(i,e)}else t=e;"scrollBehavior"in document.documentElement.style?window.scrollTo(t):window.scrollTo(t.left!=null?t.left:window.scrollX,t.top!=null?t.top:window.scrollY)}function Qi(e,t){return(history.state?history.state.position-t:-1)+e}const Ks=new Map;function ku(e,t){Ks.set(e,t)}function Ou(e){const t=Ks.get(e);return Ks.delete(e),t}function Iu(e){return typeof e=="string"||e&&typeof e=="object"}function ko(e){return typeof e=="string"||typeof e=="symbol"}let de=(function(e){return e[e.MATCHER_NOT_FOUND=1]="MATCHER_NOT_FOUND",e[e.NAVIGATION_GUARD_REDIRECT=2]="NAVIGATION_GUARD_REDIRECT",e[e.NAVIGATION_ABORTED=4]="NAVIGATION_ABORTED",e[e.NAVIGATION_CANCELLED=8]="NAVIGATION_CANCELLED",e[e.NAVIGATION_DUPLICATED=16]="NAVIGATION_DUPLICATED",e})({});const Oo=Symbol("");de.MATCHER_NOT_FOUND+"",de.NAVIGATION_GUARD_REDIRECT+"",de.NAVIGATION_ABORTED+"",de.NAVIGATION_CANCELLED+"",de.NAVIGATION_DUPLICATED+"";function nn(e,t){return te(new Error,{type:e,[Oo]:!0},t)}function ht(e,t){return e instanceof Error&&Oo in e&&(t==null||!!(e.type&t))}const Nu=["params","query","hash"];function Lu(e){if(typeof e=="string")return e;if(e.path!=null)return e.path;const t={};for(const n of Nu)n in e&&(t[n]=e[n]);return JSON.stringify(t,null,2)}function Du(e){const t={};if(e===""||e==="?")return t;const n=(e[0]==="?"?e.slice(1):e).split("&");for(let s=0;si&&js(i)):[s&&js(s)]).forEach(i=>{i!==void 0&&(t+=(t.length?"&":"")+n,i!=null&&(t+="="+i))})}return t}function Hu(e){const t={};for(const n in e){const s=e[n];s!==void 0&&(t[n]=Ze(s)?s.map(i=>i==null?null:""+i):s==null?s:""+s)}return t}const Vu=Symbol(""),Zi=Symbol(""),ps=Symbol(""),fi=Symbol(""),zs=Symbol("");function ln(){let e=[];function t(s){return e.push(s),()=>{const i=e.indexOf(s);i>-1&&e.splice(i,1)}}function n(){e=[]}return{add:t,list:()=>e.slice(),reset:n}}function Tt(e,t,n,s,i,r=o=>o()){const o=s&&(s.enterCallbacks[i]=s.enterCallbacks[i]||[]);return()=>new Promise((a,l)=>{const u=g=>{g===!1?l(nn(de.NAVIGATION_ABORTED,{from:n,to:t})):g instanceof Error?l(g):Iu(g)?l(nn(de.NAVIGATION_GUARD_REDIRECT,{from:t,to:g})):(o&&s.enterCallbacks[i]===o&&typeof g=="function"&&o.push(g),a())},c=r(()=>e.call(s&&s.instances[i],t,n,u));let p=Promise.resolve(c);e.length<3&&(p=p.then(u)),p.catch(g=>l(g))})}function Rs(e,t,n,s,i=r=>r()){const r=[];for(const o of e)for(const a in o.components){let l=o.components[a];if(!(t!=="beforeRouteEnter"&&!o.instances[a]))if(So(l)){const u=(l.__vccOpts||l)[t];u&&r.push(Tt(u,n,s,o,a,i))}else{let u=l();r.push(()=>u.then(c=>{if(!c)throw new Error(`Couldn't resolve component "${a}" at "${o.path}"`);const p=ou(c)?c.default:c;o.mods[a]=c,o.components[a]=p;const g=(p.__vccOpts||p)[t];return g&&Tt(g,n,s,o,a,i)()}))}}return r}function $u(e,t){const n=[],s=[],i=[],r=Math.max(t.matched.length,e.matched.length);for(let o=0;otn(u,a))?s.push(a):n.push(a));const l=e.matched[o];l&&(t.matched.find(u=>tn(u,l))||i.push(l))}return[n,s,i]}/*!
+ * vue-router v4.6.4
+ * (c) 2025 Eduardo San Martin Morote
+ * @license MIT
+ */let Fu=()=>location.protocol+"//"+location.host;function Io(e,t){const{pathname:n,search:s,hash:i}=t,r=e.indexOf("#");if(r>-1){let o=i.includes(e.slice(r))?e.slice(r).length:1,a=i.slice(o);return a[0]!=="/"&&(a="/"+a),Yi(a,"")}return Yi(n,e)+s+i}function Bu(e,t,n,s){let i=[],r=[],o=null;const a=({state:g})=>{const v=Io(e,location),T=n.value,C=t.value;let y=0;if(g){if(n.value=v,t.value=g,o&&o===T){o=null;return}y=C?g.position-C.position:0}else s(v);i.forEach($=>{$(n.value,T,{delta:y,type:Gs.pop,direction:y?y>0?As.forward:As.back:As.unknown})})};function l(){o=n.value}function u(g){i.push(g);const v=()=>{const T=i.indexOf(g);T>-1&&i.splice(T,1)};return r.push(v),v}function c(){if(document.visibilityState==="hidden"){const{history:g}=window;if(!g.state)return;g.replaceState(te({},g.state,{scroll:ds()}),"")}}function p(){for(const g of r)g();r=[],window.removeEventListener("popstate",a),window.removeEventListener("pagehide",c),document.removeEventListener("visibilitychange",c)}return window.addEventListener("popstate",a),window.addEventListener("pagehide",c),document.addEventListener("visibilitychange",c),{pauseListeners:l,listen:u,destroy:p}}function er(e,t,n,s=!1,i=!1){return{back:e,current:t,forward:n,replaced:s,position:window.history.length,scroll:i?ds():null}}function Uu(e){const{history:t,location:n}=window,s={value:Io(e,n)},i={value:t.state};i.value||r(s.value,{back:null,current:s.value,forward:null,position:t.length-1,replaced:!0,scroll:null},!0);function r(l,u,c){const p=e.indexOf("#"),g=p>-1?(n.host&&document.querySelector("base")?e:e.slice(p))+l:Fu()+e+l;try{t[c?"replaceState":"pushState"](u,"",g),i.value=u}catch(v){console.error(v),n[c?"replace":"assign"](g)}}function o(l,u){r(l,te({},t.state,er(i.value.back,l,i.value.forward,!0),u,{position:i.value.position}),!0),s.value=l}function a(l,u){const c=te({},i.value,t.state,{forward:l,scroll:ds()});r(c.current,c,!0),r(l,te({},er(s.value,l,null),{position:c.position+1},u),!1),s.value=l}return{location:s,state:i,push:a,replace:o}}function ju(e){e=Au(e);const t=Uu(e),n=Bu(e,t.state,t.location,t.replace);function s(r,o=!0){o||n.pauseListeners(),history.go(r)}const i=te({location:"",base:e,go:s,createHref:Mu.bind(null,e)},t,n);return Object.defineProperty(i,"location",{enumerable:!0,get:()=>t.location.value}),Object.defineProperty(i,"state",{enumerable:!0,get:()=>t.state.value}),i}function Gu(e){return e=location.host?e||location.pathname+location.search:"",e.includes("#")||(e+="#"),ju(e)}let Ht=(function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.Group=2]="Group",e})({});var ye=(function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.ParamRegExp=2]="ParamRegExp",e[e.ParamRegExpEnd=3]="ParamRegExpEnd",e[e.EscapeNext=4]="EscapeNext",e})(ye||{});const Ku={type:Ht.Static,value:""},zu=/[a-zA-Z0-9_]/;function Wu(e){if(!e)return[[]];if(e==="/")return[[Ku]];if(!e.startsWith("/"))throw new Error(`Invalid path "${e}"`);function t(v){throw new Error(`ERR (${n})/"${u}": ${v}`)}let n=ye.Static,s=n;const i=[];let r;function o(){r&&i.push(r),r=[]}let a=0,l,u="",c="";function p(){u&&(n===ye.Static?r.push({type:Ht.Static,value:u}):n===ye.Param||n===ye.ParamRegExp||n===ye.ParamRegExpEnd?(r.length>1&&(l==="*"||l==="+")&&t(`A repeatable param (${u}) must be alone in its segment. eg: '/:ids+.`),r.push({type:Ht.Param,value:u,regexp:c,repeatable:l==="*"||l==="+",optional:l==="*"||l==="?"})):t("Invalid state to consume buffer"),u="")}function g(){u+=l}for(;at.length?t.length===1&&t[0]===ke.Static+ke.Segment?1:-1:0}function No(e,t){let n=0;const s=e.score,i=t.score;for(;n0&&t[t.length-1]<0}const Xu={strict:!1,end:!0,sensitive:!1};function Zu(e,t,n){const s=Ju(Wu(e.path),n),i=te(s,{record:e,parent:t,children:[],alias:[]});return t&&!i.record.aliasOf==!t.record.aliasOf&&t.children.push(i),i}function ec(e,t){const n=[],s=new Map;t=qi(Xu,t);function i(p){return s.get(p)}function r(p,g,v){const T=!v,C=ir(p);C.aliasOf=v&&v.record;const y=qi(t,p),$=[C];if("alias"in p){const V=typeof p.alias=="string"?[p.alias]:p.alias;for(const Z of V)$.push(ir(te({},C,{components:v?v.record.components:C.components,path:Z,aliasOf:v?v.record:C})))}let E,N;for(const V of $){const{path:Z}=V;if(g&&Z[0]!=="/"){const fe=g.record.path,re=fe[fe.length-1]==="/"?"":"/";V.path=g.record.path+(Z&&re+Z)}if(E=Zu(V,g,y),v?v.alias.push(E):(N=N||E,N!==E&&N.alias.push(E),T&&p.name&&!rr(E)&&o(p.name)),Lo(E)&&l(E),C.children){const fe=C.children;for(let re=0;re{o(N)}:yn}function o(p){if(ko(p)){const g=s.get(p);g&&(s.delete(p),n.splice(n.indexOf(g),1),g.children.forEach(o),g.alias.forEach(o))}else{const g=n.indexOf(p);g>-1&&(n.splice(g,1),p.record.name&&s.delete(p.record.name),p.children.forEach(o),p.alias.forEach(o))}}function a(){return n}function l(p){const g=sc(p,n);n.splice(g,0,p),p.record.name&&!rr(p)&&s.set(p.record.name,p)}function u(p,g){let v,T={},C,y;if("name"in p&&p.name){if(v=s.get(p.name),!v)throw nn(de.MATCHER_NOT_FOUND,{location:p});y=v.record.name,T=te(sr(g.params,v.keys.filter(N=>!N.optional).concat(v.parent?v.parent.keys.filter(N=>N.optional):[]).map(N=>N.name)),p.params&&sr(p.params,v.keys.map(N=>N.name))),C=v.stringify(T)}else if(p.path!=null)C=p.path,v=n.find(N=>N.re.test(C)),v&&(T=v.parse(C),y=v.record.name);else{if(v=g.name?s.get(g.name):n.find(N=>N.re.test(g.path)),!v)throw nn(de.MATCHER_NOT_FOUND,{location:p,currentLocation:g});y=v.record.name,T=te({},g.params,p.params),C=v.stringify(T)}const $=[];let E=v;for(;E;)$.unshift(E.record),E=E.parent;return{name:y,path:C,params:T,matched:$,meta:nc($)}}e.forEach(p=>r(p));function c(){n.length=0,s.clear()}return{addRoute:r,resolve:u,removeRoute:o,clearRoutes:c,getRoutes:a,getRecordMatcher:i}}function sr(e,t){const n={};for(const s of t)s in e&&(n[s]=e[s]);return n}function ir(e){const t={path:e.path,redirect:e.redirect,name:e.name,meta:e.meta||{},aliasOf:e.aliasOf,beforeEnter:e.beforeEnter,props:tc(e),children:e.children||[],instances:{},leaveGuards:new Set,updateGuards:new Set,enterCallbacks:{},components:"components"in e?e.components||null:e.component&&{default:e.component}};return Object.defineProperty(t,"mods",{value:{}}),t}function tc(e){const t={},n=e.props||!1;if("component"in e)t.default=n;else for(const s in e.components)t[s]=typeof n=="object"?n[s]:n;return t}function rr(e){for(;e;){if(e.record.aliasOf)return!0;e=e.parent}return!1}function nc(e){return e.reduce((t,n)=>te(t,n.meta),{})}function sc(e,t){let n=0,s=t.length;for(;n!==s;){const r=n+s>>1;No(e,t[r])<0?s=r:n=r+1}const i=ic(e);return i&&(s=t.lastIndexOf(i,s-1)),s}function ic(e){let t=e;for(;t=t.parent;)if(Lo(t)&&No(e,t)===0)return t}function Lo({record:e}){return!!(e.name||e.components&&Object.keys(e.components).length||e.redirect)}function or(e){const t=Ye(ps),n=Ye(fi),s=_e(()=>{const l=he(e.to);return t.resolve(l)}),i=_e(()=>{const{matched:l}=s.value,{length:u}=l,c=l[u-1],p=n.matched;if(!c||!p.length)return-1;const g=p.findIndex(tn.bind(null,c));if(g>-1)return g;const v=lr(l[u-2]);return u>1&&lr(c)===v&&p[p.length-1].path!==v?p.findIndex(tn.bind(null,l[u-2])):g}),r=_e(()=>i.value>-1&&uc(n.params,s.value.params)),o=_e(()=>i.value>-1&&i.value===n.matched.length-1&&Po(n.params,s.value.params));function a(l={}){if(ac(l)){const u=t[he(e.replace)?"replace":"push"](he(e.to)).catch(yn);return e.viewTransition&&typeof document<"u"&&"startViewTransition"in document&&document.startViewTransition(()=>u),u}return Promise.resolve()}return{route:s,href:_e(()=>s.value.href),isActive:r,isExactActive:o,navigate:a}}function rc(e){return e.length===1?e[0]:e}const oc=we({name:"RouterLink",compatConfig:{MODE:3},props:{to:{type:[String,Object],required:!0},replace:Boolean,activeClass:String,exactActiveClass:String,custom:Boolean,ariaCurrentValue:{type:String,default:"page"},viewTransition:Boolean},useLink:or,setup(e,{slots:t}){const n=Mn(or(e)),{options:s}=Ye(ps),i=_e(()=>({[ar(e.activeClass,s.linkActiveClass,"router-link-active")]:n.isActive,[ar(e.exactActiveClass,s.linkExactActiveClass,"router-link-exact-active")]:n.isExactActive}));return()=>{const r=t.default&&rc(t.default(n));return e.custom?r:wo("a",{"aria-current":n.isExactActive?e.ariaCurrentValue:null,href:n.href,onClick:n.navigate,class:i.value},r)}}}),lc=oc;function ac(e){if(!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)&&!e.defaultPrevented&&!(e.button!==void 0&&e.button!==0)){if(e.currentTarget&&e.currentTarget.getAttribute){const t=e.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(t))return}return e.preventDefault&&e.preventDefault(),!0}}function uc(e,t){for(const n in t){const s=t[n],i=e[n];if(typeof s=="string"){if(s!==i)return!1}else if(!Ze(i)||i.length!==s.length||s.some((r,o)=>r.valueOf()!==i[o].valueOf()))return!1}return!0}function lr(e){return e?e.aliasOf?e.aliasOf.path:e.path:""}const ar=(e,t,n)=>e??t??n,cc=we({name:"RouterView",inheritAttrs:!1,props:{name:{type:String,default:"default"},route:Object},compatConfig:{MODE:3},setup(e,{attrs:t,slots:n}){const s=Ye(zs),i=_e(()=>e.route||s.value),r=Ye(Zi,0),o=_e(()=>{let u=he(r);const{matched:c}=i.value;let p;for(;(p=c[u])&&!p.components;)u++;return u}),a=_e(()=>i.value.matched[o.value]);hn(Zi,_e(()=>o.value+1)),hn(Vu,a),hn(zs,i);const l=Oe();return Yt(()=>[l.value,a.value,e.name],([u,c,p],[g,v,T])=>{c&&(c.instances[p]=u,v&&v!==c&&u&&u===g&&(c.leaveGuards.size||(c.leaveGuards=v.leaveGuards),c.updateGuards.size||(c.updateGuards=v.updateGuards))),u&&c&&(!v||!tn(c,v)||!g)&&(c.enterCallbacks[p]||[]).forEach(C=>C(u))},{flush:"post"}),()=>{const u=i.value,c=e.name,p=a.value,g=p&&p.components[c];if(!g)return ur(n.default,{Component:g,route:u});const v=p.props[c],T=v?v===!0?u.params:typeof v=="function"?v(u):v:null,y=wo(g,te({},T,t,{onVnodeUnmounted:$=>{$.component.isUnmounted&&(p.instances[c]=null)},ref:l}));return ur(n.default,{Component:y,route:u})||y}}});function ur(e,t){if(!e)return null;const n=e(t);return n.length===1?n[0]:n}const fc=cc;function dc(e){const t=ec(e.routes,e),n=e.parseQuery||Du,s=e.stringifyQuery||Xi,i=e.history,r=ln(),o=ln(),a=ln(),l=gl(Rt);let u=Rt;Kt&&e.scrollBehavior&&"scrollRestoration"in history&&(history.scrollRestoration="manual");const c=Ss.bind(null,w=>""+w),p=Ss.bind(null,bu),g=Ss.bind(null,Cn);function v(w,H){let O,F;return ko(w)?(O=t.getRecordMatcher(w),F=H):F=w,t.addRoute(F,O)}function T(w){const H=t.getRecordMatcher(w);H&&t.removeRoute(H)}function C(){return t.getRoutes().map(w=>w.record)}function y(w){return!!t.getRecordMatcher(w)}function $(w,H){if(H=te({},H||l.value),typeof w=="string"){const m=Cs(n,w,H.path),_=t.resolve({path:m.path},H),x=i.createHref(m.fullPath);return te(m,_,{params:g(_.params),hash:Cn(m.hash),redirectedFrom:void 0,href:x})}let O;if(w.path!=null)O=te({},w,{path:Cs(n,w.path,H.path).path});else{const m=te({},w.params);for(const _ in m)m[_]==null&&delete m[_];O=te({},w,{params:p(m)}),H.params=p(H.params)}const F=t.resolve(O,H),Y=w.hash||"";F.params=c(g(F.params));const f=xu(s,te({},w,{hash:gu(Y),path:F.path})),d=i.createHref(f);return te({fullPath:f,hash:Y,query:s===Xi?Hu(w.query):w.query||{}},F,{redirectedFrom:void 0,href:d})}function E(w){return typeof w=="string"?Cs(n,w,l.value.path):te({},w)}function N(w,H){if(u!==w)return nn(de.NAVIGATION_CANCELLED,{from:H,to:w})}function V(w){return re(w)}function Z(w){return V(te(E(w),{replace:!0}))}function fe(w,H){const O=w.matched[w.matched.length-1];if(O&&O.redirect){const{redirect:F}=O;let Y=typeof F=="function"?F(w,H):F;return typeof Y=="string"&&(Y=Y.includes("?")||Y.includes("#")?Y=E(Y):{path:Y},Y.params={}),te({query:w.query,hash:w.hash,params:Y.path!=null?{}:w.params},Y)}}function re(w,H){const O=u=$(w),F=l.value,Y=w.state,f=w.force,d=w.replace===!0,m=fe(O,F);if(m)return re(te(E(m),{state:typeof m=="object"?te({},Y,m.state):Y,force:f,replace:d}),H||O);const _=O;_.redirectedFrom=H;let x;return!f&&Eu(s,F,O)&&(x=nn(de.NAVIGATION_DUPLICATED,{to:_,from:F}),je(F,F,!0,!1)),(x?Promise.resolve(x):D(_,F)).catch(b=>ht(b)?ht(b,de.NAVIGATION_GUARD_REDIRECT)?b:tt(b):X(b,_,F)).then(b=>{if(b){if(ht(b,de.NAVIGATION_GUARD_REDIRECT))return re(te({replace:d},E(b.to),{state:typeof b.to=="object"?te({},Y,b.to.state):Y,force:f}),H||_)}else b=et(_,F,!0,d,Y);return q(_,F,b),b})}function Ue(w,H){const O=N(w,H);return O?Promise.reject(O):Promise.resolve()}function z(w){const H=At.values().next().value;return H&&typeof H.runWithContext=="function"?H.runWithContext(w):w()}function D(w,H){let O;const[F,Y,f]=$u(w,H);O=Rs(F.reverse(),"beforeRouteLeave",w,H);for(const m of F)m.leaveGuards.forEach(_=>{O.push(Tt(_,w,H))});const d=Ue.bind(null,w,H);return O.push(d),Ge(O).then(()=>{O=[];for(const m of r.list())O.push(Tt(m,w,H));return O.push(d),Ge(O)}).then(()=>{O=Rs(Y,"beforeRouteUpdate",w,H);for(const m of Y)m.updateGuards.forEach(_=>{O.push(Tt(_,w,H))});return O.push(d),Ge(O)}).then(()=>{O=[];for(const m of f)if(m.beforeEnter)if(Ze(m.beforeEnter))for(const _ of m.beforeEnter)O.push(Tt(_,w,H));else O.push(Tt(m.beforeEnter,w,H));return O.push(d),Ge(O)}).then(()=>(w.matched.forEach(m=>m.enterCallbacks={}),O=Rs(f,"beforeRouteEnter",w,H,z),O.push(d),Ge(O))).then(()=>{O=[];for(const m of o.list())O.push(Tt(m,w,H));return O.push(d),Ge(O)}).catch(m=>ht(m,de.NAVIGATION_CANCELLED)?m:Promise.reject(m))}function q(w,H,O){a.list().forEach(F=>z(()=>F(w,H,O)))}function et(w,H,O,F,Y){const f=N(w,H);if(f)return f;const d=H===Rt,m=Kt?history.state:{};O&&(F||d?i.replace(w.fullPath,te({scroll:d&&m&&m.scroll},Y)):i.push(w.fullPath,Y)),l.value=w,je(w,H,O,d),tt()}let Le;function Pt(){Le||(Le=i.listen((w,H,O)=>{if(!kt.listening)return;const F=$(w),Y=fe(F,kt.currentRoute.value);if(Y){re(te(Y,{replace:!0,force:!0}),F).catch(yn);return}u=F;const f=l.value;Kt&&ku(Qi(f.fullPath,O.delta),ds()),D(F,f).catch(d=>ht(d,de.NAVIGATION_ABORTED|de.NAVIGATION_CANCELLED)?d:ht(d,de.NAVIGATION_GUARD_REDIRECT)?(re(te(E(d.to),{force:!0}),F).then(m=>{ht(m,de.NAVIGATION_ABORTED|de.NAVIGATION_DUPLICATED)&&!O.delta&&O.type===Gs.pop&&i.go(-1,!1)}).catch(yn),Promise.reject()):(O.delta&&i.go(-O.delta,!1),X(d,F,f))).then(d=>{d=d||et(F,f,!1),d&&(O.delta&&!ht(d,de.NAVIGATION_CANCELLED)?i.go(-O.delta,!1):O.type===Gs.pop&&ht(d,de.NAVIGATION_ABORTED|de.NAVIGATION_DUPLICATED)&&i.go(-1,!1)),q(F,f,d)}).catch(yn)}))}let St=ln(),ge=ln(),ee;function X(w,H,O){tt(w);const F=ge.list();return F.length?F.forEach(Y=>Y(w,H,O)):console.error(w),Promise.reject(w)}function ze(){return ee&&l.value!==Rt?Promise.resolve():new Promise((w,H)=>{St.add([w,H])})}function tt(w){return ee||(ee=!w,Pt(),St.list().forEach(([H,O])=>w?O(w):H()),St.reset()),w}function je(w,H,O,F){const{scrollBehavior:Y}=e;if(!Kt||!Y)return Promise.resolve();const f=!O&&Ou(Qi(w.fullPath,0))||(F||!O)&&history.state&&history.state.scroll||null;return is().then(()=>Y(w,H,f)).then(d=>d&&Pu(d)).catch(d=>X(d,w,H))}const Ae=w=>i.go(w);let Ct;const At=new Set,kt={currentRoute:l,listening:!0,addRoute:v,removeRoute:T,clearRoutes:t.clearRoutes,hasRoute:y,getRoutes:C,resolve:$,options:e,push:V,replace:Z,go:Ae,back:()=>Ae(-1),forward:()=>Ae(1),beforeEach:r.add,beforeResolve:o.add,afterEach:a.add,onError:ge.add,isReady:ze,install(w){w.component("RouterLink",lc),w.component("RouterView",fc),w.config.globalProperties.$router=kt,Object.defineProperty(w.config.globalProperties,"$route",{enumerable:!0,get:()=>he(l)}),Kt&&!Ct&&l.value===Rt&&(Ct=!0,V(i.location).catch(F=>{}));const H={};for(const F in Rt)Object.defineProperty(H,F,{get:()=>l.value[F],enumerable:!0});w.provide(ps,kt),w.provide(fi,Nr(H)),w.provide(zs,l);const O=w.unmount;At.add(w),w.unmount=function(){At.delete(w),At.size<1&&(u=Rt,Le&&Le(),Le=null,l.value=Rt,Ct=!1,ee=!1),O()}}};function Ge(w){return w.reduce((H,O)=>H.then(()=>z(O)),Promise.resolve())}return kt}function Do(){return Ye(ps)}function pc(e){return Ye(fi)}const hc=["width","height"],mc={key:8,d:"M6 4.5v15l13-7.5z",fill:"currentColor",stroke:"none"},gc={key:17,d:"m9 6 6 6-6 6"},vc={key:18,d:"m6 9 6 6 6-6"},yc={key:19,d:"m15 6-6 6 6 6"},bc={key:23,d:"M4 12.5 9 17 20 6"},_c={key:28,x:"5",y:"5",width:"14",height:"14",rx:"2",fill:"currentColor",stroke:"none"},wc={key:34,d:"M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"},me=we({__name:"Icon",props:{name:{},size:{}},setup(e){return(t,n)=>(A(),k("svg",{width:e.size??16,height:e.size??16,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"1.75","stroke-linecap":"round","stroke-linejoin":"round",class:"ico"},[e.name==="dashboard"?(A(),k(j,{key:0},[n[0]||(n[0]=h("rect",{x:"3",y:"3",width:"7",height:"9",rx:"1.5"},null,-1)),n[1]||(n[1]=h("rect",{x:"14",y:"3",width:"7",height:"5",rx:"1.5"},null,-1)),n[2]||(n[2]=h("rect",{x:"14",y:"12",width:"7",height:"9",rx:"1.5"},null,-1)),n[3]||(n[3]=h("rect",{x:"3",y:"16",width:"7",height:"5",rx:"1.5"},null,-1))],64)):e.name==="playlist"?(A(),k(j,{key:1},[n[4]||(n[4]=Lt(' ',6))],64)):e.name==="epg"?(A(),k(j,{key:2},[n[5]||(n[5]=Lt(' ',7))],64)):e.name==="import"?(A(),k(j,{key:3},[n[6]||(n[6]=h("path",{d:"M12 3v12"},null,-1)),n[7]||(n[7]=h("path",{d:"m7 10 5 5 5-5"},null,-1)),n[8]||(n[8]=h("path",{d:"M5 21h14"},null,-1))],64)):e.name==="map"?(A(),k(j,{key:4},[n[9]||(n[9]=h("circle",{cx:"6",cy:"6",r:"2.5"},null,-1)),n[10]||(n[10]=h("circle",{cx:"18",cy:"18",r:"2.5"},null,-1)),n[11]||(n[11]=h("path",{d:"M8.5 6h7a3 3 0 0 1 0 6h-7a3 3 0 0 0 0 6h-2"},null,-1))],64)):e.name==="settings"?(A(),k(j,{key:5},[n[12]||(n[12]=h("circle",{cx:"12",cy:"12",r:"3"},null,-1)),n[13]||(n[13]=h("path",{d:"M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"},null,-1))],64)):e.name==="search"?(A(),k(j,{key:6},[n[14]||(n[14]=h("circle",{cx:"11",cy:"11",r:"7"},null,-1)),n[15]||(n[15]=h("path",{d:"m21 21-4.3-4.3"},null,-1))],64)):e.name==="plus"?(A(),k(j,{key:7},[n[16]||(n[16]=h("path",{d:"M12 5v14"},null,-1)),n[17]||(n[17]=h("path",{d:"M5 12h14"},null,-1))],64)):e.name==="play"?(A(),k("path",mc)):e.name==="pause"?(A(),k(j,{key:9},[n[18]||(n[18]=h("rect",{x:"6",y:"5",width:"4",height:"14"},null,-1)),n[19]||(n[19]=h("rect",{x:"14",y:"5",width:"4",height:"14"},null,-1))],64)):e.name==="refresh"?(A(),k(j,{key:10},[n[20]||(n[20]=h("path",{d:"M3 12a9 9 0 0 1 15-6.7L21 8"},null,-1)),n[21]||(n[21]=h("path",{d:"M21 3v5h-5"},null,-1)),n[22]||(n[22]=h("path",{d:"M21 12a9 9 0 0 1-15 6.7L3 16"},null,-1)),n[23]||(n[23]=h("path",{d:"M3 21v-5h5"},null,-1))],64)):e.name==="trash"?(A(),k(j,{key:11},[n[24]||(n[24]=Lt(' ',5))],64)):e.name==="edit"?(A(),k(j,{key:12},[n[25]||(n[25]=h("path",{d:"M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"},null,-1)),n[26]||(n[26]=h("path",{d:"m18.5 2.5 3 3L12 15l-4 1 1-4z"},null,-1))],64)):e.name==="more"?(A(),k(j,{key:13},[n[27]||(n[27]=h("circle",{cx:"5",cy:"12",r:"1.4"},null,-1)),n[28]||(n[28]=h("circle",{cx:"12",cy:"12",r:"1.4"},null,-1)),n[29]||(n[29]=h("circle",{cx:"19",cy:"12",r:"1.4"},null,-1))],64)):e.name==="grid"?(A(),k(j,{key:14},[n[30]||(n[30]=h("rect",{x:"3",y:"3",width:"7",height:"7",rx:"1"},null,-1)),n[31]||(n[31]=h("rect",{x:"14",y:"3",width:"7",height:"7",rx:"1"},null,-1)),n[32]||(n[32]=h("rect",{x:"3",y:"14",width:"7",height:"7",rx:"1"},null,-1)),n[33]||(n[33]=h("rect",{x:"14",y:"14",width:"7",height:"7",rx:"1"},null,-1))],64)):e.name==="list"?(A(),k(j,{key:15},[n[34]||(n[34]=Lt(' ',6))],64)):e.name==="filter"?(A(),k(j,{key:16},[n[35]||(n[35]=h("path",{d:"M3 5h18"},null,-1)),n[36]||(n[36]=h("path",{d:"M6 12h12"},null,-1)),n[37]||(n[37]=h("path",{d:"M10 19h4"},null,-1))],64)):e.name==="chevron-r"?(A(),k("path",gc)):e.name==="chevron-d"?(A(),k("path",vc)):e.name==="chevron-l"?(A(),k("path",yc)):e.name==="upload"?(A(),k(j,{key:20},[n[38]||(n[38]=h("path",{d:"M12 15V3"},null,-1)),n[39]||(n[39]=h("path",{d:"m7 8 5-5 5 5"},null,-1)),n[40]||(n[40]=h("path",{d:"M5 21h14"},null,-1))],64)):e.name==="link"?(A(),k(j,{key:21},[n[41]||(n[41]=h("path",{d:"M10 14a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"},null,-1)),n[42]||(n[42]=h("path",{d:"M14 10a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"},null,-1))],64)):e.name==="tv"?(A(),k(j,{key:22},[n[43]||(n[43]=h("rect",{x:"2",y:"5",width:"20",height:"14",rx:"2"},null,-1)),n[44]||(n[44]=h("path",{d:"M8 22h8"},null,-1)),n[45]||(n[45]=h("path",{d:"M12 19v3"},null,-1))],64)):e.name==="check"?(A(),k("path",bc)):e.name==="x"?(A(),k(j,{key:24},[n[46]||(n[46]=h("path",{d:"M18 6 6 18"},null,-1)),n[47]||(n[47]=h("path",{d:"m6 6 12 12"},null,-1))],64)):e.name==="sync"?(A(),k(j,{key:25},[n[48]||(n[48]=h("path",{d:"M21 12a9 9 0 0 1-15 6.7L3 16"},null,-1)),n[49]||(n[49]=h("path",{d:"M3 12a9 9 0 0 1 15-6.7L21 8"},null,-1))],64)):e.name==="warn"?(A(),k(j,{key:26},[n[50]||(n[50]=h("path",{d:"M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.41 0z"},null,-1)),n[51]||(n[51]=h("path",{d:"M12 9v4"},null,-1)),n[52]||(n[52]=h("circle",{cx:"12",cy:"17",r:"0.6",fill:"currentColor"},null,-1))],64)):e.name==="add"?(A(),k(j,{key:27},[n[53]||(n[53]=h("circle",{cx:"12",cy:"12",r:"9"},null,-1)),n[54]||(n[54]=h("path",{d:"M12 8v8"},null,-1)),n[55]||(n[55]=h("path",{d:"M8 12h8"},null,-1))],64)):e.name==="stop"?(A(),k("rect",_c)):e.name==="globe"?(A(),k(j,{key:29},[n[56]||(n[56]=h("circle",{cx:"12",cy:"12",r:"9"},null,-1)),n[57]||(n[57]=h("path",{d:"M3 12h18"},null,-1)),n[58]||(n[58]=h("path",{d:"M12 3a14 14 0 0 1 0 18"},null,-1)),n[59]||(n[59]=h("path",{d:"M12 3a14 14 0 0 0 0 18"},null,-1))],64)):e.name==="file"?(A(),k(j,{key:30},[n[60]||(n[60]=h("path",{d:"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"},null,-1)),n[61]||(n[61]=h("path",{d:"M14 2v6h6"},null,-1))],64)):e.name==="logout"?(A(),k(j,{key:31},[n[62]||(n[62]=h("path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"},null,-1)),n[63]||(n[63]=h("path",{d:"m16 17 5-5-5-5"},null,-1)),n[64]||(n[64]=h("path",{d:"M21 12H9"},null,-1))],64)):e.name==="copy"?(A(),k(j,{key:32},[n[65]||(n[65]=h("rect",{x:"8",y:"8",width:"12",height:"12",rx:"2"},null,-1)),n[66]||(n[66]=h("path",{d:"M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"},null,-1))],64)):e.name==="sun"?(A(),k(j,{key:33},[n[67]||(n[67]=Lt(' ',9))],64)):e.name==="moon"?(A(),k("path",wc)):He("",!0)],8,hc))}}),xc=["title","disabled"],$e=we({__name:"Btn",props:{variant:{},size:{},icon:{},title:{},disabled:{type:Boolean}},setup(e){const t=e,n=Kl(),s=_e(()=>{const i=!!n.default;return["btn",t.variant,t.size,t.icon&&!i?"icon":""].filter(Boolean).join(" ")});return(i,r)=>(A(),k("button",{class:Be(s.value),title:e.title,disabled:e.disabled},[e.icon?(A(),We(me,{key:0,name:e.icon,size:e.size==="sm"?13:14},null,8,["name","size"])):He("",!0),as(i.$slots,"default")],10,xc))}}),Ec={class:"twk-body"},jt=16,Sc=we({__name:"TweaksPanel",props:{title:{}},setup(e){const t=Oe(!1),n=Oe(null),s=Oe({x:16,y:16});function i(){const l=n.value;if(!l)return;const u=l.offsetWidth,c=l.offsetHeight,p=Math.max(jt,window.innerWidth-u-jt),g=Math.max(jt,window.innerHeight-c-jt);s.value={x:Math.min(p,Math.max(jt,s.value.x)),y:Math.min(g,Math.max(jt,s.value.y))}}function r(l){var c;const u=(c=l==null?void 0:l.data)==null?void 0:c.type;u==="__activate_edit_mode"?t.value=!0:u==="__deactivate_edit_mode"&&(t.value=!1)}function o(){var l;t.value=!1;try{(l=window.parent)==null||l.postMessage({type:"__edit_mode_dismissed"},"*")}catch{}}function a(l){const u=n.value;if(!u)return;const c=u.getBoundingClientRect(),p=l.clientX,g=l.clientY,v=window.innerWidth-c.right,T=window.innerHeight-c.bottom,C=$=>{s.value={x:v-($.clientX-p),y:T-($.clientY-g)},i()},y=()=>{window.removeEventListener("mousemove",C),window.removeEventListener("mouseup",y)};window.addEventListener("mousemove",C),window.addEventListener("mouseup",y)}return os(()=>{var l;window.addEventListener("message",r);try{(l=window.parent)==null||l.postMessage({type:"__edit_mode_available"},"*")}catch{}window.addEventListener("resize",i)}),ls(()=>{window.removeEventListener("message",r),window.removeEventListener("resize",i)}),(l,u)=>t.value?(A(),k("div",{key:0,ref_key:"panel",ref:n,class:"twk-panel",style:dt({right:s.value.x+"px",bottom:s.value.y+"px"})},[h("div",{class:"twk-hd",onMousedown:a},[h("b",null,J(e.title||"Tweaks"),1),h("button",{class:"twk-x","aria-label":"Close tweaks",onMousedown:u[0]||(u[0]=Eo(()=>{},["stop"])),onClick:o},"✕",32)],32),h("div",Ec,[as(l.$slots,"default")])],4)):He("",!0)}}),Cc={class:"twk-sect"},Ms=we({__name:"TweakSection",props:{label:{}},setup(e){return(t,n)=>(A(),k("div",Cc,J(e.label),1))}}),Ac={class:"twk-row"},Rc={class:"twk-lbl"},Mc={class:"twk-seg",role:"radiogroup"},Tc=["aria-checked","onClick"],Ts=we({__name:"TweakRadio",props:{label:{},value:{},options:{}},emits:["change"],setup(e,{emit:t}){const n=e,s=t,i=_e(()=>Math.max(0,n.options.indexOf(n.value))),r=_e(()=>n.options.length);return(o,a)=>(A(),k("div",Ac,[h("div",Rc,[h("span",null,J(e.label),1)]),h("div",Mc,[h("div",{class:"twk-seg-thumb",style:dt({left:`calc(2px + ${i.value} * (100% - 4px) / ${r.value})`,width:`calc((100% - 4px) / ${r.value})`})},null,4),(A(!0),k(j,null,Ft(e.options,l=>(A(),k("button",{key:l,type:"button",role:"radio","aria-checked":l===e.value,onClick:u=>s("change",l)},J(l),9,Tc))),128))])]))}}),cn=we({__name:"Pill",props:{tone:{}},setup(e){return(t,n)=>(A(),k("span",{class:Be(["pill",e.tone||""])},[as(t.$slots,"default")],2))}}),Pc=["title"],kc=we({__name:"StatusDot",props:{status:{},pulse:{type:Boolean}},setup(e){return(t,n)=>(A(),k("div",{class:Be(["dot",e.status||"idle",{pulse:e.pulse}]),title:e.status},null,10,Pc))}}),Oc=we({__name:"ChannelLogo",props:{ch:{},size:{}},setup(e){return(t,n)=>(A(),k("div",{class:Be(["ch-logo",e.size||""]),style:dt({background:e.ch.logoColor+" linear-gradient(135deg, transparent, rgba(0,0,0,.25))",color:"white"})},J(e.ch.initials),7))}}),Ic={class:"segmented"},Nc=["onClick"],Ho=we({__name:"Segmented",props:{value:{},options:{}},emits:["change"],setup(e,{emit:t}){const n=e,s=t;return(i,r)=>(A(),k("div",Ic,[(A(!0),k(j,null,Ft(n.options,o=>(A(),k("button",{key:o.value,class:Be(e.value===o.value?"active":""),onClick:a=>s("change",o.value)},[o.icon?(A(),We(me,{key:0,name:o.icon,size:13},null,8,["name"])):He("",!0),pe(" "+J(o.label),1)],10,Nc))),128))]))}}),Nn=[{id:"pl-default",name:"Default",url:"bundled://tvapp2/default.m3u",channels:24,groups:6,lastSync:"Ships with TVApp2",status:"good",auto:!0,interval:"Auto-updated",builtin:!0},{id:"pl-iptv-pro",name:"IPTV-Pro Main",url:"https://iptv-pro.example.com/playlist.m3u8",channels:142,groups:8,lastSync:"2 minutes ago",status:"good",auto:!0,interval:"Every 6 hours"},{id:"pl-free-uk",name:"Free UK Bouquet",url:"https://iptv-org.github.io/iptv/countries/uk.m3u",channels:87,groups:4,lastSync:"1 hour ago",status:"good",auto:!0,interval:"Daily"},{id:"pl-archive",name:"Archive (legacy)",url:"file:///playlists/archive-2023.m3u",channels:38,groups:3,lastSync:"3 days ago",status:"warn",auto:!1,interval:"Manual"}],Ln=[{id:"epg-default",name:"Default",url:"bundled://tvapp2/default.xml.gz",channels:86,programs:5240,lastSync:"Ships with TVApp2",status:"good",auto:!0,interval:"Auto-updated",builtin:!0},{id:"epg-xmltv-uk",name:"XMLTV UK Guide",url:"https://epg.example.com/uk.xml.gz",channels:124,programs:8420,lastSync:"12 minutes ago",status:"good",auto:!0,interval:"Every 12 hours"},{id:"epg-iptv-org",name:"iptv-org world EPG",url:"https://iptv-org.github.io/epg/guides/uk/openepg.xml",channels:412,programs:24180,lastSync:"2 hours ago",status:"good",auto:!0,interval:"Daily"}],Lc=["News","Sport","Entertainment","Movies","Kids","Music","Documentary","Lifestyle"];function Dc(e){let t=e;return()=>(t=t*1664525+1013904223>>>0,t/4294967296)}const Hc=[{tvg_name:"BBC One HD",group:"Entertainment",channel:101,tvg_id:"bbc.one.uk",state:"active",epg:"matched",status:"good",res:"1080p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"BBC Two HD",group:"Entertainment",channel:102,tvg_id:"bbc.two.uk",state:"active",epg:"matched",status:"good",res:"1080p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"BBC News",group:"News",channel:231,tvg_id:"bbc.news.uk",state:"active",epg:"matched",status:"good",res:"720p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"Sky Sports Main",group:"Sport",channel:401,tvg_id:"sky.sports.main.uk",state:"active",epg:"matched",status:"good",res:"1080p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"Sky Sports F1",group:"Sport",channel:406,tvg_id:"sky.sports.f1.uk",state:"active",epg:"matched",status:"good",res:"1080p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"ITV1 HD",group:"Entertainment",channel:103,tvg_id:"itv1.uk",state:"active",epg:"matched",status:"good",res:"1080p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"Channel 4 HD",group:"Entertainment",channel:104,tvg_id:"channel4.uk",state:"active",epg:"matched",status:"warn",res:"1080p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"Film4",group:"Movies",channel:315,tvg_id:"film4.uk",state:"active",epg:"matched",status:"good",res:"720p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"Discovery Channel",group:"Documentary",channel:520,tvg_id:"discovery.uk",state:"active",epg:"matched",status:"good",res:"1080p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"National Geographic",group:"Documentary",channel:521,tvg_id:"natgeo.uk",state:"active",epg:"unmatched",status:"good",res:"1080p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"CNN International",group:"News",channel:233,tvg_id:"cnn.int",state:"active",epg:"matched",status:"good",res:"720p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"Al Jazeera English",group:"News",channel:235,tvg_id:"aljazeera.en",state:"active",epg:"matched",status:"good",res:"720p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"Cartoon Network",group:"Kids",channel:601,tvg_id:"cartoonnet.uk",state:"active",epg:"matched",status:"good",res:"720p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"Nick Jr.",group:"Kids",channel:615,tvg_id:null,state:"disabled",epg:"unmatched",status:"warn",res:"720p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"MTV Hits",group:"Music",channel:365,tvg_id:"mtv.hits.uk",state:"active",epg:"matched",status:"good",res:"720p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"Kerrang!",group:"Music",channel:369,tvg_id:"kerrang.uk",state:"active",epg:"matched",status:"good",res:"720p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"Food Network",group:"Lifestyle",channel:240,tvg_id:"foodnet.uk",state:"active",epg:"matched",status:"good",res:"720p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"HGTV",group:"Lifestyle",channel:242,tvg_id:null,state:"disabled",epg:"unmatched",status:"bad",res:"720p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"TCM Movies",group:"Movies",channel:320,tvg_id:"tcm.uk",state:"active",epg:"matched",status:"good",res:"1080p",url:"http://sample.stream.com/channel/1.m3u8"},{tvg_name:"Eurosport 1",group:"Sport",channel:410,tvg_id:"eurosport1.uk",state:"active",epg:"matched",status:"good",res:"1080p",url:"http://sample.stream.com/channel/1.m3u8"}],Ws=Hc.map((e,t)=>({id:`ch-${t}`,...e,source:"Default",logoColor:`oklch(0.5 0.16 ${t*47%360})`,initials:e.tvg_name.split(/\s+/).slice(0,2).map(n=>n[0]).join("").toUpperCase()})),Ps=[["Morning News","Live"],["Breakfast Show","Lifestyle"],["Market Report","Business"],["Sports Roundup","Highlights"],["Drama Hour","Series"],["World Headlines","News"],["Wildlife Special","Documentary"],["Cooking with Anna","Lifestyle"],["Classic Movies","Film"],["Talk of the Day","Discussion"],["Children's Hour","Kids"],["Weather Watch","Weather"],["Live Match","Football"],["Tech Today","Technology"],["Late Show","Comedy"],["Documentary","Feature"],["Music Mix","Music"],["Quiz Night","Game show"],["Reality TV","Series"],["The Daily Brief","News"]],Td=Array.from({length:25},(e,t)=>t);function Vc(e,t){const n=Dc(t),s=[];let i=0;for(;i<24;){const r=[.5,1,1,1.5,2][Math.floor(n()*5)],o=Math.floor(n()*Ps.length);s.push({start:i,end:Math.min(24,i+r),title:Ps[o][0],cat:Ps[o][1]}),i+=r}return s}const $c={};Ws.slice(0,12).forEach((e,t)=>{$c[e.id]=Vc(e.id,100+t*7)});const Pd=[{when:"2m",icon:"sync",html:"IPTV-Pro Main synced — 142 channels, no changes"},{when:"12m",icon:"epg",html:"XMLTV UK Guide imported — 8,420 programs across 124 channels"},{when:"1h",icon:"map",html:'Manual mapping: HGTV → hgtv.uk'},{when:"1h",icon:"warn",html:"Free UK Bouquet reports 3 channels offline (HTTP 503)"},{when:"3h",icon:"edit",html:"Renamed Discovery → Discovery Channel "},{when:"Yest.",icon:"add",html:"Playlist IPTV-Pro Main added (142 channels)"}],cr=[{id:"as-1",channelId:"ch-0",status:"good",uptime:"4h 12m",uptimeMin:252,viewers:142,peakViewers:168,bitrate:6.4,targetBitrate:6,codec:"H.264 High@4.1",audio:"AAC LC 2.0 · 128k",container:"HLS / TS",resolution:"1920×1080",fps:50,sourceUrl:"http://stream.iptv-pro.example.com/live/bbc-one/index.m3u8",sourceHost:"edge-fra-04",droppedFrames:0,droppedRatio:0,latency:2.1,bandwidth:912},{id:"as-2",channelId:"ch-3",status:"good",uptime:"1h 48m",uptimeMin:108,viewers:89,peakViewers:112,bitrate:8.2,targetBitrate:8,codec:"H.264 High@4.2",audio:"AC3 5.1 · 384k",container:"HLS / fMP4",resolution:"1920×1080",fps:50,sourceUrl:"http://stream.iptv-pro.example.com/live/sky-sports-main/index.m3u8",sourceHost:"edge-lon-02",droppedFrames:14,droppedRatio:.01,latency:1.8,bandwidth:730},{id:"as-3",channelId:"ch-2",status:"good",uptime:"22h 06m",uptimeMin:1326,viewers:47,peakViewers:61,bitrate:3.1,targetBitrate:3,codec:"H.264 Main@3.1",audio:"AAC LC 2.0 · 96k",container:"HLS / TS",resolution:"1280×720",fps:25,sourceUrl:"http://stream.iptv-pro.example.com/live/bbc-news/index.m3u8",sourceHost:"edge-fra-04",droppedFrames:2,droppedRatio:0,latency:2.4,bandwidth:145},{id:"as-4",channelId:"ch-6",status:"warn",uptime:"12m",uptimeMin:12,viewers:8,peakViewers:8,bitrate:4.1,targetBitrate:5,codec:"H.264 High@4.0",audio:"AAC LC 2.0 · 128k",container:"HLS / TS",resolution:"1920×1080",fps:25,sourceUrl:"http://stream.iptv-pro.example.com/live/channel4/index.m3u8",sourceHost:"edge-ams-01",droppedFrames:184,droppedRatio:.47,latency:4.7,bandwidth:34},{id:"as-5",channelId:"ch-4",status:"good",uptime:"3h 41m",uptimeMin:221,viewers:31,peakViewers:44,bitrate:7.9,targetBitrate:8,codec:"H.264 High@4.2",audio:"AC3 5.1 · 384k",container:"HLS / fMP4",resolution:"1920×1080",fps:50,sourceUrl:"http://stream.iptv-pro.example.com/live/sky-sports-f1/index.m3u8",sourceHost:"edge-lon-02",droppedFrames:0,droppedRatio:0,latency:1.9,bandwidth:245},{id:"as-6",channelId:"ch-8",status:"good",uptime:"45m",uptimeMin:45,viewers:12,peakViewers:12,bitrate:5.6,targetBitrate:6,codec:"H.265 Main@4.0",audio:"AAC LC 2.0 · 128k",container:"HLS / fMP4",resolution:"1920×1080",fps:25,sourceUrl:"http://stream.iptv-pro.example.com/live/discovery/index.m3u8",sourceHost:"edge-fra-04",droppedFrames:1,droppedRatio:0,latency:2.2,bandwidth:68},{id:"as-7",channelId:"ch-17",status:"bad",uptime:"—",uptimeMin:0,viewers:0,peakViewers:4,bitrate:0,targetBitrate:5,codec:"—",audio:"—",container:"HLS / TS",resolution:"—",fps:0,sourceUrl:"http://stream.iptv-pro.example.com/live/hgtv/index.m3u8",sourceHost:"edge-ams-01",droppedFrames:0,droppedRatio:0,latency:0,bandwidth:0}],kd=[{ip:"82.14.221.47",region:"GB · London",client:"VLC / Linux",joined:"2m ago",bitrate:"6.4 Mbps"},{ip:"192.81.45.12",region:"DE · Frankfurt",client:"Tivimate / Android TV",joined:"8m ago",bitrate:"6.4 Mbps"},{ip:"104.18.92.5",region:"NL · Amsterdam",client:"OTT Navigator / FireTV",joined:"14m ago",bitrate:"3.1 Mbps"},{ip:"176.58.103.9",region:"GB · Manchester",client:"Kodi 21",joined:"31m ago",bitrate:"6.4 Mbps"},{ip:"78.143.211.4",region:"FR · Paris",client:"IPTV Smarters / iOS",joined:"1h ago",bitrate:"3.1 Mbps"},{ip:"10.0.4.118",region:"Local · LAN",client:"ffmpeg / probe",joined:"3h ago",bitrate:"6.4 Mbps"}];function Od(e,t){let n=e;const s=()=>(n=n*1664525+1013904223>>>0,n/4294967296),i=[];for(let r=0;r<60;r++){const o=(s()-.5)*.6;i.push(Math.max(.2,t+o))}return i}const Id=[{id:"cust-sports-night",name:"Sports Night",slug:"sports-night",channels:12,updated:"2 days ago"},{id:"cust-kids-safe",name:"Kids Safe",slug:"kids-safe",channels:8,updated:"yesterday"},{id:"cust-news-rotation",name:"News Rotation",slug:"news-rotation",channels:6,updated:"5 hours ago"},{id:"cust-living-room",name:"Living Room Favorites",slug:"living-room",channels:18,updated:"1 week ago"}],Fc={class:"drawer-wrap"},Bc={class:"glass drawer-panel"},Uc={class:"drawer-hd"},jc={style:{flex:"1"}},Gc={style:{"font-weight":"600","font-size":"15px"}},Kc={class:"mono muted",style:{"font-size":"var(--fs-xs)","margin-top":"2px"}},zc={class:"drawer-body"},Wc={class:"player"},qc={class:"label mono"},Yc={class:"play"},Jc={class:"play-btn"},Qc={class:"controls"},Xc={class:"row",style:{gap:"6px"}},Zc={class:"form-row"},ef={class:"row",style:{gap:"10px"}},tf={class:"form-row"},nf={class:"input"},sf=["value"],rf={class:"form-grid-2"},of={class:"form-row"},lf={class:"input"},af=["value"],uf={class:"form-row"},cf={class:"select"},ff=["value"],df={class:"form-row"},pf={class:"input"},hf=["value"],mf={class:"form-row"},gf={class:"input"},vf=["value"],yf={class:"form-row"},bf={class:"input mono",style:{"font-size":"11px"}},_f=["value"],wf={class:"row",style:{"margin-top":"6px"}},xf=we({__name:"ChannelDrawer",props:{ch:{}},emits:["close"],setup(e,{emit:t}){const n=e,s=t;function i(r){n.ch.state=r}return(r,o)=>(A(),k("div",Fc,[h("div",{class:"glass-bg drawer-backdrop",onClick:o[0]||(o[0]=a=>s("close"))}),h("div",Bc,[h("div",Uc,[L(Oc,{ch:e.ch,size:"lg"},null,8,["ch"]),h("div",jc,[h("div",Gc,J(e.ch.tvg_name),1),h("div",Kc," #"+J(e.ch.channel)+" · "+J(e.ch.group)+" · "+J(e.ch.res),1)]),L($e,{variant:"ghost",size:"sm",icon:"x",onClick:o[1]||(o[1]=a=>s("close"))})]),h("div",zc,[h("div",Wc,[o[7]||(o[7]=h("div",{class:"stripes"},null,-1)),h("div",qc,"STREAM TEST · "+J(e.ch.res),1),h("div",Yc,[h("div",Jc,[L(me,{name:"play",size:26})])]),h("div",Qc,[L(me,{name:"pause",size:14}),o[4]||(o[4]=h("span",{class:"mono",style:{"font-size":"11px"}},"00:14",-1)),o[5]||(o[5]=h("div",{class:"track"},null,-1)),o[6]||(o[6]=h("span",{class:"mono",style:{"font-size":"11px"}},"LIVE",-1))])]),h("div",Xc,[L(cn,{tone:"good"},{default:be(()=>[L(kc,{status:"good"}),o[8]||(o[8]=pe(" stream live",-1))]),_:1}),L(cn,{tone:"cyan"},{default:be(()=>[L(me,{name:"globe",size:11}),o[9]||(o[9]=pe(" 1280×720",-1))]),_:1}),L(cn,null,{default:be(()=>[...o[10]||(o[10]=[pe("h.264 / AAC",-1)])]),_:1}),o[12]||(o[12]=h("span",{class:"spacer"},null,-1)),L($e,{variant:"ghost",size:"sm",icon:"refresh"},{default:be(()=>[...o[11]||(o[11]=[pe("Re-test",-1)])]),_:1})]),o[24]||(o[24]=h("div",{class:"divider"},null,-1)),h("div",Zc,[o[13]||(o[13]=h("div",{class:"field-lbl"},"State",-1)),h("div",ef,[L(Ho,{value:e.ch.state,onChange:i,options:[{value:"active",label:"Active",icon:"check"},{value:"disabled",label:"Disabled",icon:"x"}]},null,8,["value"]),L(cn,{tone:e.ch.state==="active"?"active":"disabled"},{default:be(()=>[pe(J(e.ch.state==="active"?"active":"disabled"),1)]),_:1},8,["tone"])])]),h("div",tf,[o[14]||(o[14]=h("div",{class:"field-lbl"},"Display name",-1)),h("div",nf,[h("input",{value:e.ch.tvg_name},null,8,sf)])]),h("div",rf,[h("div",of,[o[15]||(o[15]=h("div",{class:"field-lbl"},"Channel number",-1)),h("div",lf,[h("input",{value:e.ch.channel},null,8,af)])]),h("div",uf,[o[16]||(o[16]=h("div",{class:"field-lbl"},"Group",-1)),h("div",cf,[h("select",{value:e.ch.group},[(A(!0),k(j,null,Ft(he(Lc),a=>(A(),k("option",{key:a},J(a),1))),128))],8,ff)])])]),h("div",df,[o[17]||(o[17]=h("div",{class:"field-lbl"},"Source",-1)),h("div",pf,[L(me,{name:"playlist",size:14}),h("input",{value:e.ch.source,placeholder:"e.g. Default"},null,8,hf)])]),h("div",mf,[o[18]||(o[18]=h("div",{class:"field-lbl"},"TVG-ID (EPG link)",-1)),h("div",gf,[L(me,{name:"link",size:14}),h("input",{value:e.ch.tvg_id||"",placeholder:"e.g. bbc.one.uk"},null,8,vf)])]),h("div",yf,[o[19]||(o[19]=h("div",{class:"field-lbl"},"URL",-1)),h("div",bf,[L(me,{name:"link",size:14}),h("input",{value:e.ch.url,placeholder:"https://example.com/live/channel/index.m3u8"},null,8,_f)])]),h("div",wf,[L($e,{variant:"ghost",icon:"trash"},{default:be(()=>[...o[20]||(o[20]=[h("span",{style:{color:"var(--bad)"}},"Remove",-1)])]),_:1}),o[23]||(o[23]=h("span",{class:"spacer"},null,-1)),L($e,{variant:"ghost",onClick:o[2]||(o[2]=a=>s("close"))},{default:be(()=>[...o[21]||(o[21]=[pe("Cancel",-1)])]),_:1}),L($e,{variant:"primary",icon:"check",onClick:o[3]||(o[3]=a=>s("close"))},{default:be(()=>[...o[22]||(o[22]=[pe("Save changes",-1)])]),_:1})])])])]))}}),Ef=["aria-checked"],Sf=we({__name:"Toggle",props:{on:{type:Boolean}},emits:["change"],setup(e,{emit:t}){const n=e,s=t;return(i,r)=>(A(),k("div",{class:Be(["toggle",{on:n.on}]),role:"switch","aria-checked":!!n.on,onClick:r[0]||(r[0]=o=>s("change",!n.on))},null,10,Ef))}}),Cf={class:"row",style:{"align-items":"center",padding:"10px 0"}},Af={style:{flex:"1"}},Rf={style:{"font-weight":"500","font-size":"var(--fs-base)"}},Mf={class:"muted",style:{"font-size":"var(--fs-xs)","margin-top":"2px"}},Tf=we({__name:"SettingsRow",props:{label:{},hint:{}},setup(e){return(t,n)=>(A(),k("div",Cf,[h("div",Af,[h("div",Rf,J(e.label),1),h("div",Mf,J(e.hint),1)]),as(t.$slots,"right")]))}}),Pf={class:"modal-hd"},kf={class:"modal-body"},Of={class:"form-row"},If={class:"input"},Nf=["value"],Lf={class:"form-row"},Df={class:"input"},Hf=["placeholder"],Vf={class:"modal-ft"},$f=we({__name:"AddSourceModal",props:{kind:{}},emits:["close"],setup(e,{emit:t}){const n=e,s=t,i=Do(),r=n.kind==="playlist"?"M3U Playlist":"EPG Source";function o(){s("close"),i.push(n.kind==="playlist"?"/playlists":"/epg-sources")}function a(){s("close"),i.push("/import")}return(l,u)=>(A(),k("div",{class:"modal-bg",onClick:u[3]||(u[3]=c=>s("close"))},[h("div",{class:"modal",onClick:u[2]||(u[2]=Eo(()=>{},["stop"]))},[h("div",Pf,[L(me,{name:e.kind==="playlist"?"playlist":"epg",size:18},null,8,["name"]),h("h2",null,"Add "+J(he(r)),1),u[4]||(u[4]=h("span",{class:"spacer"},null,-1)),L($e,{variant:"ghost",size:"sm",icon:"x",onClick:u[0]||(u[0]=c=>s("close"))})]),h("div",kf,[h("div",Of,[u[5]||(u[5]=h("div",{class:"field-lbl"},"Name",-1)),h("div",If,[h("input",{value:`My ${he(r)}`},null,8,Nf)])]),h("div",Lf,[u[6]||(u[6]=h("div",{class:"field-lbl"},"Remote URL",-1)),h("div",Df,[L(me,{name:"link",size:14}),h("input",{placeholder:e.kind==="playlist"?"https://provider.example.com/playlist.m3u":"https://example.com/guide.xml.gz"},null,8,Hf)])]),u[9]||(u[9]=Lt('',1)),L(Tf,{label:"Auto-match channels to EPG",hint:"Run fuzzy matching right after the first import."},{right:be(()=>[L(Sf,{on:!0})]),_:1}),h("div",{class:"muted",style:{"font-size":"var(--fs-xs)",padding:"4px 0"}},[u[7]||(u[7]=pe(" Tip: you can also drag a file into the ",-1)),h("span",{style:{color:"var(--accent-hi)",cursor:"default"},onClick:a},"Import"),u[8]||(u[8]=pe(" screen. ",-1))])]),h("div",Vf,[L($e,{variant:"ghost",onClick:u[1]||(u[1]=c=>s("close"))},{default:be(()=>[...u[10]||(u[10]=[pe("Cancel",-1)])]),_:1}),L($e,{variant:"primary",icon:"check",onClick:o},{default:be(()=>[...u[11]||(u[11]=[pe("Add & sync",-1)])]),_:1})])])]))}}),Ff=["value","placeholder"],Bf=we({__name:"SearchInput",props:{value:{},placeholder:{},width:{}},emits:["change"],setup(e,{emit:t}){const n=e,s=t;return(i,r)=>(A(),k("div",{class:"input",style:dt({width:(n.width||260)+"px"})},[L(me,{name:"search",size:14}),h("input",{value:e.value,placeholder:e.placeholder||"Search",onInput:r[0]||(r[0]=o=>s("change",o.target.value))},null,40,Ff)],4))}}),Uf={class:"logs-drawer-wrap"},jf={class:"glass logs-drawer"},Gf={class:"logs-hd"},Kf={class:"row",style:{gap:"8px"}},zf={key:0,class:"live-pill",style:{background:"oklch(0.78 0.16 150 / 0.18)",color:"var(--good)","border-color":"oklch(0.78 0.16 150 / 0.4)"}},Wf={class:"muted mono",style:{"font-size":"11px","margin-left":"6px"}},qf={class:"select",style:{"min-width":"110px"}},Yf=["value"],Jf={key:0,class:"empty",style:{padding:"40px"}},Qf={class:"log-ts mono"},Xf={class:"log-src"},Zf={class:"log-msg"},ed=we({__name:"LogsDrawer",emits:["close"],setup(e,{emit:t}){const n=t,s=["sync","epg","stream","match","api","core"],i=[{v:"info",weight:60,color:"var(--text-2)"},{v:"ok",weight:15,color:"var(--good)"},{v:"warn",weight:18,color:"var(--warn)"},{v:"error",weight:4,color:"var(--bad)"},{v:"debug",weight:3,color:"var(--text-3)"}],r=[["sync","info","Refreshing playlist {pl}"],["sync","ok","Playlist {pl} synced in {ms}ms · {n} channels, +{added} −{removed}"],["sync","warn","Playlist {pl} returned 3 channel(s) with 503 — retry in 30s"],["epg","info","Fetching EPG source {epg}"],["epg","ok","Indexed {programs} programs from {epg} in {ms}ms"],["epg","debug","Parsed nodes: {programs}"],["stream","info","Probing upstream {url}"],["stream","ok","Stream {ch} healthy · {bitrate} Mbps, latency {lat}s"],["stream","warn","Stream {ch} dropped {n} frames in last 60s"],["stream","error","Stream {ch} upstream HTTP 503 — pausing relay"],["match","info","Auto-matching {n} channels against EPG IDs"],["match","ok","Mapped {ch} → {tvg}"],["match","warn","Could not auto-match {ch} (no candidate over 0.8 confidence)"],["api","info","GET /v1/playlists/{pl} 200 · {ms}ms"],["api","info","GET /v1/epg/{epg}/programs 200 · {ms}ms"],["api","warn","GET /v1/streams 429 · rate limit"],["core","info","Cache warm: {n} entries, {kb}KB"],["core","debug","GC reclaimed {kb}KB"]];function o(z){const D=r[z%r.length],q=["Default","IPTV-Pro Main","Free UK Bouquet","Archive (legacy)"][z%4],et=["Default","XMLTV UK Guide","iptv-org world EPG"][z%3],Le=Ws[z%Ws.length],Pt=Le.tvg_id||"bbc.one.uk",St=`http://edge-fra-04/live/${Le.id.replace("ch-","ch")}/index.m3u8`,ge=60+z*17%800,ee=(3+z*5%50/10).toFixed(1),X=(1.2+z*7%30/10).toFixed(1),ze=z*11%12+1,tt=z%7,je=(z>>1)%4,Ae=1500+z*53%9e3,Ct=50+z*31%950,At=D[2].replace("{pl}",q).replace("{epg}",et).replace("{ch}",Le.tvg_name).replace("{tvg}",Pt).replace("{url}",St).replace("{ms}",String(ge)).replace("{bitrate}",ee).replace("{lat}",X).replace("{n}",String(ze)).replace("{added}",String(tt)).replace("{removed}",String(je)).replace("{programs}",Ae.toLocaleString()).replace("{kb}",String(Ct));return{source:D[0],level:D[1],text:At}}const a=Array.from({length:36},(z,D)=>{const q=o(D*13+5);return{ts:new Date(Date.now()-(36-D)*(1200+D*137)),id:"seed-"+D,...q}}),l=Oe(a),u=Oe(!1),c=Oe("all"),p=Oe("all"),g=Oe(""),v=Oe(!0),T=Oe(null);let C=0,y=null;function $(){y&&clearInterval(y),y=window.setInterval(()=>{if(u.value)return;C++;const z={...o(Date.now()+C),ts:new Date,id:"live-"+C},D=[...l.value,z];l.value=D.length>400?D.slice(D.length-400):D},700+Math.random()*900)}function E(z){z.key==="Escape"&&n("close")}os(()=>{$(),window.addEventListener("keydown",E)}),ls(()=>{y&&clearInterval(y),window.removeEventListener("keydown",E)}),Yt(l,async()=>{v.value&&T.value&&(await is(),T.value.scrollTop=T.value.scrollHeight)});const N=_e(()=>l.value.filter(z=>(c.value==="all"||c.value===z.level||c.value==="issues"&&(z.level==="warn"||z.level==="error"))&&(p.value==="all"||z.source===p.value)&&(g.value===""||z.text.toLowerCase().includes(g.value.toLowerCase())))),V=_e(()=>({total:l.value.length,warn:l.value.filter(z=>z.level==="warn").length,error:l.value.filter(z=>z.level==="error").length}));function Z(z){const D=z.target,q=D.scrollHeight-D.scrollTop-D.clientHeight<24;q!==v.value&&(v.value=q)}function fe(){v.value=!0,T.value&&(T.value.scrollTop=T.value.scrollHeight)}function re(z){return z.toLocaleTimeString("en-GB",{hour12:!1})+"."+String(z.getMilliseconds()).padStart(3,"0")}function Ue(z){var D;return((D=i.find(q=>q.v===z))==null?void 0:D.color)||"var(--text-2)"}return(z,D)=>(A(),k("div",Uf,[h("div",{class:"glass-bg logs-drawer-backdrop",onClick:D[0]||(D[0]=q=>n("close"))}),h("div",jf,[h("div",Gf,[h("div",Kf,[L(me,{name:"file",size:15}),D[9]||(D[9]=h("span",{style:{"font-weight":"600","font-size":"14px"}},"Realtime logs",-1)),u.value?He("",!0):(A(),k("span",zf,[...D[7]||(D[7]=[h("span",{class:"dot",style:{background:"var(--good)","box-shadow":"0 0 8px var(--good)"}},null,-1),pe("STREAMING ",-1)])])),u.value?(A(),We(cn,{key:1,tone:"warn"},{default:be(()=>[L(me,{name:"pause",size:11}),D[8]||(D[8]=pe("paused",-1))]),_:1})):He("",!0),h("span",Wf,J(V.value.total)+" lines · "+J(V.value.warn)+" warn · "+J(V.value.error)+" err ",1)]),L(Bf,{value:g.value,onChange:D[1]||(D[1]=q=>g.value=q),placeholder:"Filter logs",width:220},null,8,["value"]),h("div",qf,[Al(h("select",{"onUpdate:modelValue":D[2]||(D[2]=q=>p.value=q)},[D[10]||(D[10]=h("option",{value:"all"},"All sources",-1)),(A(),k(j,null,Ft(s,q=>h("option",{key:q,value:q},J(q),9,Yf)),64))],512),[[Xa,p.value]])]),L(Ho,{value:c.value,onChange:D[3]||(D[3]=q=>c.value=q),options:[{value:"all",label:"All"},{value:"info",label:"Info"},{value:"issues",label:"Issues"},{value:"debug",label:"Debug"}]},null,8,["value"]),D[13]||(D[13]=h("span",{class:"spacer"},null,-1)),L($e,{variant:"ghost",size:"sm",icon:u.value?"play":"pause",onClick:D[4]||(D[4]=q=>u.value=!u.value)},{default:be(()=>[pe(J(u.value?"Resume":"Pause"),1)]),_:1},8,["icon"]),L($e,{variant:"ghost",size:"sm",icon:"trash",onClick:D[5]||(D[5]=q=>l.value=[]),title:"Clear"},{default:be(()=>[...D[11]||(D[11]=[pe("Clear",-1)])]),_:1}),L($e,{variant:"ghost",size:"sm",icon:"upload",title:"Download log"},{default:be(()=>[...D[12]||(D[12]=[pe("Export",-1)])]),_:1}),L($e,{variant:"ghost",size:"sm",icon:"x",onClick:D[6]||(D[6]=q=>n("close")),title:"Close (Esc)"})]),h("div",{class:"logs-body",ref_key:"body",ref:T,onScroll:Z},[N.value.length===0?(A(),k("div",Jf,[...D[14]||(D[14]=[h("h3",null,"No log lines match",-1),h("p",null,"Adjust the search or level filter.",-1)])])):(A(!0),k(j,{key:1},Ft(N.value,q=>(A(),k("div",{key:q.id,class:Be(`log-line log-${q.level}`)},[h("span",Qf,J(re(q.ts)),1),h("span",{class:"log-lvl",style:dt({color:Ue(q.level)})},J(q.level.toUpperCase()),5),h("span",Xf,J(q.source),1),h("span",Zf,J(q.text),1)],2))),128))],544),v.value?He("",!0):(A(),k("button",{key:0,class:"logs-scroll-btn",onClick:fe},[L(me,{name:"chevron-d",size:12}),D[15]||(D[15]=pe("Jump to live ",-1))]))])]))}}),qn=Mn({theme:"dark",density:"regular",epgMode:"timeline"});Yt(()=>[qn.theme,qn.density],([e,t])=>{document.documentElement.dataset.theme=e,document.documentElement.dataset.density=t},{immediate:!0});function td(){function e(t,n){var i;const s=typeof t=="object"&&t!==null?t:{[t]:n};Object.assign(qn,s);try{(i=window.parent)==null||i.postMessage({type:"__edit_mode_set_keys",edits:s},"*")}catch{}}return{tweaks:qn,setTweak:e}}function nd(e){return{all:e=e||new Map,on:function(t,n){var s=e.get(t);s?s.push(n):e.set(t,[n])},off:function(t,n){var s=e.get(t);s&&(n?s.splice(s.indexOf(n)>>>0,1):e.set(t,[]))},emit:function(t,n){var s=e.get(t);s&&s.slice().map(function(i){i(n)}),(s=e.get("*"))&&s.slice().map(function(i){i(t,n)})}}}const ks=nd(),sd={class:"app"},id={class:"sidebar"},rd=["onClick"],od={key:0,class:"dot good pulse",style:{width:"6px",height:"6px"}},ld={key:1,class:"count"},ad=["onClick"],ud={class:"sidebar-foot-stack"},cd={class:"logs-btn-ico"},fd={class:"main"},dd={class:"topbar"},pd={key:0,class:"restore-strip",role:"status","aria-live":"polite"},hd={class:"restore-strip-line"},md={class:"restore-strip-label"},gd={class:"restore-strip-bar"},vd={class:"restore-strip-pct mono"},yd={class:"crumb",style:{"margin-left":"6px"}},bd=["title"],_d={class:"theme-toggle-ico"},wd={class:"theme-toggle-ico"},xd=we({__name:"App",setup(e){const{tweaks:t,setTweak:n}=td(),s=Do(),i=pc(),r=Oe(null),o=Oe(null),a=Oe(!1),l=Oe(null);hn("openChannel",C=>{r.value=C});const u=_e(()=>[{id:"dashboard",label:"Dashboard",icon:"dashboard",path:"/dashboard"},{id:"active",label:"Active Streams",icon:"tv",path:"/active",count:cr.filter(C=>C.status!=="bad").length,live:!0},{id:"playlists",label:"Playlists",icon:"playlist",path:"/playlists",count:Nn.length},{id:"epg-sources",label:"EPG Sources",icon:"epg",path:"/epg-sources",count:Ln.length},{id:"mapping",label:"Channel Mapping",icon:"map",path:"/mapping"},{id:"history",label:"History / Metrics",icon:"file",path:"/history"},{id:"import",label:"Import",icon:"import",path:"/import"},{id:"settings",label:"Settings",icon:"settings",path:"/settings"}]);function c(C){const y=i.name;return y?C==="playlists"&&y==="playlist"||C==="epg-sources"&&y==="epg-detail"?!0:C===y:!1}const p=_e(()=>{switch(i.name){case"dashboard":return{title:"Dashboard",crumb:"Overview"};case"active":return{title:"Active Streams",crumb:`${cr.filter(y=>y.status!=="bad").length} live now`};case"playlists":return{title:"Playlists",crumb:`${Nn.length} sources`};case"epg-sources":return{title:"EPG Sources",crumb:`${Ln.length} sources`};case"mapping":return{title:"Channel Mapping",crumb:"M3U ↔ EPG"};case"history":return{title:"History / Metrics",crumb:"Streaming history"};case"import":return{title:"Import",crumb:"M3U / XMLTV"};case"settings":return{title:"Settings",crumb:"Workspace"};case"playlist":{const y=Nn.find($=>$.id===i.params.id)||Nn[0];return{title:y.name,parent:"Playlists",parentPath:"/playlists",crumb:y.name}}case"epg-detail":{const y=Ln.find($=>$.id===i.params.id)||Ln[0];return{title:y.name,parent:"EPG Sources",parentPath:"/epg-sources",crumb:y.name}}default:return{title:"",crumb:""}}}),g=_e(()=>i.name==="epg-detail"||i.name==="active"?{display:"flex",flexDirection:"column"}:null);function v(C){s.push(C),r.value=null}function T(C){const y=C.items||[];if(!y.length)return;const $=5,E=y.length*$;let N=0;l.value={items:y,idx:0,percent:0,label:y[0].text,kind:y[0].kind};const V=window.setInterval(()=>{N++;const Z=Math.min(100,Math.round(N/E*100)),fe=Math.min(y.length-1,Math.floor(N/$)),re=y[fe];l.value={items:y,idx:fe,percent:Z,label:re.text,kind:re.kind},N>=E&&(clearInterval(V),setTimeout(()=>{l.value=null,ks.emit("tvapp:restore-done")},520))},220)}return os(()=>ks.on("tvapp:restore-start",T)),ls(()=>ks.off("tvapp:restore-start",T)),(C,y)=>{const $=Ul("router-view");return A(),k("div",sd,[h("aside",id,[y[15]||(y[15]=h("div",{class:"brand"},[h("span",{class:"brand-dot"}),pe(" TVApp2 ")],-1)),y[16]||(y[16]=h("div",{class:"nav-group-label"},"Workspace",-1)),(A(!0),k(j,null,Ft(u.value.slice(0,6),E=>(A(),k("div",{key:E.id,class:Be(["nav-item",{active:c(E.id)}]),onClick:N=>v(E.path)},[L(me,{name:E.icon},null,8,["name"]),h("span",null,J(E.label),1),E.live?(A(),k("span",od)):He("",!0),E.count!==void 0?(A(),k("span",ld,J(E.count),1)):He("",!0)],10,rd))),128)),y[17]||(y[17]=h("div",{class:"nav-group-label"},"Actions",-1)),(A(!0),k(j,null,Ft(u.value.slice(6),E=>(A(),k("div",{key:E.id,class:Be(["nav-item",{active:c(E.id)}]),onClick:N=>v(E.path)},[L(me,{name:E.icon},null,8,["name"]),h("span",null,J(E.label),1)],10,ad))),128)),h("div",ud,[h("button",{class:"logs-btn",onClick:y[0]||(y[0]=E=>a.value=!0)},[h("span",cd,[L(me,{name:"file",size:14}),y[11]||(y[11]=h("span",{class:"dot good pulse",style:{width:"6px",height:"6px"}},null,-1))]),y[12]||(y[12]=h("span",{style:{flex:"1","text-align":"left"}},"View logs",-1)),y[13]||(y[13]=h("span",{class:"mono",style:{"font-size":"10px",color:"var(--text-3)"}},"live",-1))]),y[14]||(y[14]=Lt('',1))])]),h("main",fd,[h("header",dd,[h("h1",null,J(p.value.title),1),l.value?(A(),k("div",pd,[h("div",hd,[L(me,{name:l.value.kind||"refresh",size:12},null,8,["name"]),y[18]||(y[18]=h("span",{class:"restore-strip-action"},"Restoring",-1)),h("span",md,J(l.value.label),1)]),h("div",gd,[h("div",{class:"restore-strip-fill",style:dt({width:l.value.percent+"%"})},null,4)]),h("span",vd,J(l.value.percent)+"%",1)])):(A(),k(j,{key:1},[h("span",yd,[p.value.parent?(A(),k(j,{key:0},[h("span",{style:{cursor:"default"},onClick:y[1]||(y[1]=E=>v(p.value.parentPath))},J(p.value.parent),1),y[19]||(y[19]=h("span",{style:{color:"var(--text-3)",margin:"0 6px"}},"›",-1))],64)):He("",!0),pe(" "+J(p.value.crumb),1)]),y[20]||(y[20]=h("span",{class:"topbar-spacer"},null,-1))],64)),h("button",{class:"theme-toggle",onClick:y[2]||(y[2]=E=>he(n)("theme",he(t).theme==="dark"?"light":"dark")),title:he(t).theme==="dark"?"Switch to light mode":"Switch to dark mode","aria-label":"Toggle theme"},[h("span",{class:Be(["theme-toggle-thumb",he(t).theme==="dark"?"is-dark":"is-light"])},[L(me,{name:he(t).theme==="dark"?"moon":"sun",size:13},null,8,["name"])],2),h("span",_d,[L(me,{name:"sun",size:13})]),h("span",wd,[L(me,{name:"moon",size:13})])],8,bd),he(i).name==="dashboard"?(A(),We($e,{key:2,variant:"primary",icon:"plus",onClick:y[3]||(y[3]=E=>v("/import"))},{default:be(()=>[...y[21]||(y[21]=[pe("New source",-1)])]),_:1})):He("",!0)]),h("div",{class:"screen",style:dt(g.value||void 0)},[L($,null,{default:be(({Component:E})=>[(A(),We(jl(E),{onAdd:y[4]||(y[4]=N=>o.value=N)},null,32))]),_:1})],4)]),r.value?(A(),We(xf,{key:0,ch:r.value,onClose:y[5]||(y[5]=E=>r.value=null)},null,8,["ch"])):He("",!0),o.value?(A(),We($f,{key:1,kind:o.value,onClose:y[6]||(y[6]=E=>o.value=null)},null,8,["kind"])):He("",!0),a.value?(A(),We(ed,{key:2,onClose:y[7]||(y[7]=E=>a.value=!1)})):He("",!0),L(Sc,{title:"Tweaks"},{default:be(()=>[L(Ms,{label:"Theme"}),L(Ts,{label:"Mode",value:he(t).theme,options:["light","dark"],onChange:y[8]||(y[8]=E=>he(n)("theme",E))},null,8,["value"]),L(Ms,{label:"Layout"}),L(Ts,{label:"Density",value:he(t).density,options:["compact","regular","spacious"],onChange:y[9]||(y[9]=E=>he(n)("density",E))},null,8,["value"]),L(Ms,{label:"EPG view"}),L(Ts,{label:"Style",value:he(t).epgMode,options:["timeline","list"],onChange:y[10]||(y[10]=E=>he(n)("epgMode",E))},null,8,["value"])]),_:1})])}}}),Ed="modulepreload",Sd=function(e){return"/"+e},fr={},ot=function(t,n,s){let i=Promise.resolve();if(n&&n.length>0){let o=function(u){return Promise.all(u.map(c=>Promise.resolve(c).then(p=>({status:"fulfilled",value:p}),p=>({status:"rejected",reason:p}))))};document.getElementsByTagName("link");const a=document.querySelector("meta[property=csp-nonce]"),l=(a==null?void 0:a.nonce)||(a==null?void 0:a.getAttribute("nonce"));i=o(n.map(u=>{if(u=Sd(u),u in fr)return;fr[u]=!0;const c=u.endsWith(".css"),p=c?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${u}"]${p}`))return;const g=document.createElement("link");if(g.rel=c?"stylesheet":Ed,c||(g.as="script"),g.crossOrigin="",g.href=u,l&&g.setAttribute("nonce",l),document.head.appendChild(g),c)return new Promise((v,T)=>{g.addEventListener("load",v),g.addEventListener("error",()=>T(new Error(`Unable to preload CSS for ${u}`)))})}))}function r(o){const a=new Event("vite:preloadError",{cancelable:!0});if(a.payload=o,window.dispatchEvent(a),!a.defaultPrevented)throw o}return i.then(o=>{for(const a of o||[])a.status==="rejected"&&r(a.reason);return t().catch(r)})},Cd=[{path:"/",redirect:"/active"},{path:"/dashboard",name:"dashboard",component:()=>ot(()=>import("./DashboardScreen-C8mAn1wl.js"),[])},{path:"/active",name:"active",component:()=>ot(()=>import("./ActiveStreamsScreen-Df8Wdk9n.js"),[])},{path:"/playlists",name:"playlists",component:()=>ot(()=>import("./PlaylistsScreen-0ooKY6SX.js"),__vite__mapDeps([0,1]))},{path:"/playlists/:id",name:"playlist",component:()=>ot(()=>import("./PlaylistDetailScreen-F92VSAQ7.js"),__vite__mapDeps([2,1,3])),props:!0},{path:"/epg-sources",name:"epg-sources",component:()=>ot(()=>import("./EPGSourcesScreen-Bh2qXoOm.js"),[])},{path:"/epg-sources/:id",name:"epg-detail",component:()=>ot(()=>import("./EPGDetailScreen-CX4y1Ve9.js"),__vite__mapDeps([4,3])),props:!0},{path:"/mapping",name:"mapping",component:()=>ot(()=>import("./MappingScreen-BdiMBcth.js"),__vite__mapDeps([5,3]))},{path:"/history",name:"history",component:()=>ot(()=>import("./HistoryMetricsScreen-DAcDrLQC.js"),[])},{path:"/import",name:"import",component:()=>ot(()=>import("./ImportScreen-D7vLRk6-.js"),[])},{path:"/settings",name:"settings",component:()=>ot(()=>import("./SettingsScreen-Daj_a2gr.js"),__vite__mapDeps([6,1]))}],Ad=dc({history:Gu(),routes:Cd});su(xd).use(Ad).mount("#app");export{cr as A,ls as B,Ws as C,os as D,Td as E,j as F,Lc as G,A as H,Mn as I,Oe as J,Ft as K,as as L,Od as M,J as N,he as O,Nn as P,Do as Q,td as R,kd as S,Xa as T,Md as U,Yt as V,Rd as W,be as X,Al as Y,Eo as Z,cn as _,Pd as a,Id as b,$c as c,Ln as d,$e as e,me as f,kc as g,Ho as h,Oc as i,Bf as j,Sf as k,Tf as l,ks as m,_e as n,h as o,We as p,He as q,k as r,Lt as s,pe as t,L as u,we as v,Ye as w,Te as x,Be as y,dt as z};
diff --git a/dist/assets/useSettings-CPUgOpin.js b/dist/assets/useSettings-CPUgOpin.js
new file mode 100644
index 00000000..39d96b90
--- /dev/null
+++ b/dist/assets/useSettings-CPUgOpin.js
@@ -0,0 +1 @@
+import{n as c,J as s,I as l}from"./index-CQPQcDLN.js";const h=s("TVApp2 Workspace"),u=s("https://tvapp2.example.com"),o=s("/m3u/playlist.m3u8"),$=s("/epg/guide.xml.gz"),m=c(()=>`${u.value.replace(/\/$/,"")}${o.value.startsWith("/")?"":"/"}${o.value}`),e=l({});function i(t){return e[t]||(e[t]={active:!0,endpointMode:"global",customPath:`/playlists/${t}.m3u`}),e[t]}function d(t){const a=i(t),n=u.value.replace(/\/$/,"");if(a.endpointMode==="custom"){const p=a.customPath.startsWith("/")?a.customPath:`/${a.customPath}`;return`${n}${p}`}return m.value}export{u as a,o as b,h as d,$ as e,m,d as p,i as u};
diff --git a/dist/index.html b/dist/index.html
new file mode 100644
index 00000000..ded17823
--- /dev/null
+++ b/dist/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ TVApp2 — M3U & EPG Manager
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dist/types-node/vite.config.d.ts b/dist/types-node/vite.config.d.ts
new file mode 100644
index 00000000..340562af
--- /dev/null
+++ b/dist/types-node/vite.config.d.ts
@@ -0,0 +1,2 @@
+declare const _default: import("vite").UserConfig;
+export default _default;
diff --git a/dist/types-node/vite.config.js b/dist/types-node/vite.config.js
new file mode 100644
index 00000000..59bf3c52
--- /dev/null
+++ b/dist/types-node/vite.config.js
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vite';
+import vue from '@vitejs/plugin-vue';
+export default defineConfig({
+ plugins: [vue()],
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:3000',
+ changeOrigin: true,
+ },
+ },
+ },
+});
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..91c3edd8
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,84 @@
+# Pinned image versions — bump in lockstep with docker/*.Dockerfile.
+# MONGO = 7.0.15
+# Requires Docker Engine >= 24.0 and Docker Compose >= 2.20 (Compose Spec).
+# The legacy top-level `version:` key is intentionally omitted (deprecated in Compose v2).
+
+services:
+ # One-shot init: writes ${TVAPP2_CONFIG_DIR}/config.json from the MONGO_ROOT_USER /
+ # MONGO_ROOT_PASS values in .env. The JSON template is embedded below — NO host file is
+ # bind-mounted — so a deployment host needs only this compose file + .env, nothing else to
+ # ship. (Bind-mounting a host template is a footgun: if the file is missing Docker silently
+ # creates it as an empty *directory*, and config.json ends up empty.) Left untouched if an
+ # existing config.json already targets the compose "mongo" host; regenerated otherwise
+ # (e.g. a stale @127.0.0.1: config left by an all-in-one run).
+ config-init:
+ image: alpine:3.20
+ environment:
+ MONGO_ROOT_USER: ${MONGO_ROOT_USER}
+ MONGO_ROOT_PASS: ${MONGO_ROOT_PASS}
+ volumes:
+ - ${TVAPP2_CONFIG_DIR}:/out
+ command:
+ - sh
+ - -c
+ - |
+ set -eu
+ if [ -s /out/config.json ] && grep -q '@mongo:' /out/config.json; then
+ echo "[config-init] /out/config.json already targets @mongo: — leaving alone"
+ exit 0
+ fi
+ if [ -s /out/config.json ]; then
+ echo "[config-init] /out/config.json does not target @mongo: — regenerating"
+ fi
+ printf '{\n "mongoUri": "mongodb://%s:%s@mongo:27017/tvapp2?authSource=admin",\n "port": 3000,\n "logLevel": "info"\n}\n' \
+ "$$MONGO_ROOT_USER" "$$MONGO_ROOT_PASS" > /out/config.json
+ echo "[config-init] wrote /out/config.json"
+ restart: "no"
+
+ app:
+ image: iflip721/tvapp2-app-stack:2.0.5-dev
+ build:
+ context: .
+ dockerfile: docker/app.Dockerfile
+ ports:
+ - "3000:3000"
+ volumes:
+ # config.json is generated by the config-init service from .env (embedded template).
+ # Set TVAPP2_CONFIG_DIR in .env to the host directory that holds it.
+ - ${TVAPP2_CONFIG_DIR}:/etc/tvapp2:rw
+ depends_on:
+ config-init:
+ condition: service_completed_successfully
+ mongo:
+ condition: service_healthy
+ networks: [tvnet]
+ restart: unless-stopped
+
+ mongo:
+ image: mongo:7.0.15
+ environment:
+ MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER}
+ MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASS}
+ ports:
+ # Expose mongod on the host. Override the host-side port with
+ # MONGO_HOST_PORT in .env (defaults to 27017).
+ - "${MONGO_HOST_PORT:-27017}:27017"
+ volumes:
+ # Persistent MongoDB data. Set MONGO_DATA_PATH in .env to an absolute
+ # host path (e.g. /srv/tvapp2/mongo) for a bind mount; leave unset to
+ # use the named volume defined below.
+ - ${MONGO_DATA_PATH:-mongo-data}:/data/db
+ healthcheck:
+ test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks: [tvnet]
+ restart: unless-stopped
+
+volumes:
+ # Fallback named volume — used only when MONGO_DATA_PATH is unset.
+ mongo-data:
+
+networks:
+ tvnet:
diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile
new file mode 100644
index 00000000..af1bd491
--- /dev/null
+++ b/docker/app.Dockerfile
@@ -0,0 +1,65 @@
+# syntax=docker/dockerfile:1.7
+# -----------------------------------------------------------------------------
+# docker/app.Dockerfile — TVApp2 "app stack" image (iflip721/tvapp2-app-stack)
+#
+# Three-stage build:
+# 1) spa-build — Vue 3 + Vite SPA → /spa/dist (root package.json)
+# 2) server-build — Express API (tsc) → /server/dist (server/package.json)
+# 3) runtime — prod-only Node; serves API + built SPA on :3000
+#
+# Runtime layout (must match server/src/index.ts publicDir and sources/paths.ts SEED_SOURCES_DIR):
+# /app/dist/ compiled server (dist/index.js, dist/sources/paths.js)
+# /app/public/ built SPA (resolve(,'..','public') => /app/public)
+# /app/seed-data/ source bundles (resolve(,'..','..','seed-data','sources'))
+# /app/package.json server pkg (type:module) + node_modules (express, mongoose only)
+#
+# Node pin: 22.11.0 LTS "Jod" on alpine 3.20 — keep in lockstep with CLAUDE.md.
+# -----------------------------------------------------------------------------
+ARG NODE_IMAGE=node:22.11.0-alpine3.20
+
+# ---- Stage 1: build the SPA (root package) ----------------------------------
+FROM ${NODE_IMAGE} AS spa-build
+WORKDIR /spa
+COPY package.json package-lock.json ./
+RUN npm ci
+COPY tsconfig.json tsconfig.node.json vite.config.ts index.html ./
+COPY public/ ./public/
+COPY src/ ./src/
+RUN npm run build # vue-tsc -b && vite build -> /spa/dist
+
+# ---- Stage 2: build the server (server package) -----------------------------
+FROM ${NODE_IMAGE} AS server-build
+WORKDIR /server
+COPY server/package.json server/package-lock.json ./
+RUN npm ci # devDeps (typescript) needed to compile
+COPY server/tsconfig.json ./
+COPY server/src/ ./src/
+RUN npm run build # tsc -p . -> /server/dist
+
+# ---- Stage 3: runtime -------------------------------------------------------
+FROM ${NODE_IMAGE} AS runtime
+ENV NODE_ENV=production \
+ TVAPP2_CONFIG=/etc/tvapp2/config.json
+WORKDIR /app
+
+# tini = correct PID 1 (forwards SIGTERM/SIGINT to the graceful-shutdown handler in index.ts).
+RUN apk add --no-cache tini
+
+# Prod-only server deps (express, mongoose).
+COPY server/package.json server/package-lock.json ./
+RUN npm ci --omit=dev && npm cache clean --force
+
+# Compiled server + built SPA + committed seed bundles.
+COPY --from=server-build /server/dist ./dist
+COPY --from=spa-build /spa/dist ./public
+COPY server/seed-data ./seed-data
+
+USER node
+EXPOSE 3000
+
+# Liveness: HTTP server up (body also reports mongo connected/disconnected).
+HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
+ CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
+
+ENTRYPOINT ["/sbin/tini", "--"]
+CMD ["node", "dist/index.js"]
diff --git a/index.html b/index.html
new file mode 100644
index 00000000..08156764
--- /dev/null
+++ b/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+ TVApp2 — M3U & EPG Manager
+
+
+
+
+
+
+
+
+
+
diff --git a/m3u-data/export - M3U - Grid.xlsx b/m3u-data/export - M3U - Grid.xlsx
new file mode 100644
index 00000000..1c0fe676
Binary files /dev/null and b/m3u-data/export - M3U - Grid.xlsx differ
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 00000000..4ddc2214
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1575 @@
+{
+ "name": "tvapp2",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "tvapp2",
+ "version": "0.1.0",
+ "dependencies": {
+ "hls.js": "^1.6.16",
+ "mitt": "^3.0.1",
+ "vue": "^3.5.13",
+ "vue-router": "^4.5.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.2.1",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.5",
+ "vue-tsc": "^2.2.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
+ "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
+ "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
+ "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
+ "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
+ "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
+ "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
+ "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
+ "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
+ "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
+ "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
+ "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
+ "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
+ "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
+ "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
+ "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
+ "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
+ "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
+ "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
+ "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
+ "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
+ "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
+ "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
+ "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+ "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@volar/language-core": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
+ "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/source-map": "2.4.15"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz",
+ "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz",
+ "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.35",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz",
+ "integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@vue/shared": "3.5.35",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.35",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz",
+ "integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.35",
+ "@vue/shared": "3.5.35"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.35",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz",
+ "integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@vue/compiler-core": "3.5.35",
+ "@vue/compiler-dom": "3.5.35",
+ "@vue/compiler-ssr": "3.5.35",
+ "@vue/shared": "3.5.35",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.15",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.35",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz",
+ "integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.35",
+ "@vue/shared": "3.5.35"
+ }
+ },
+ "node_modules/@vue/compiler-vue2": {
+ "version": "2.7.16",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
+ "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "de-indent": "^1.0.2",
+ "he": "^1.2.0"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+ "license": "MIT"
+ },
+ "node_modules/@vue/language-core": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz",
+ "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "@vue/compiler-dom": "^3.5.0",
+ "@vue/compiler-vue2": "^2.7.16",
+ "@vue/shared": "^3.5.0",
+ "alien-signals": "^1.0.3",
+ "minimatch": "^9.0.3",
+ "muggle-string": "^0.4.1",
+ "path-browserify": "^1.0.1"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.35",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz",
+ "integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.35"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.35",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.35.tgz",
+ "integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.35",
+ "@vue/shared": "3.5.35"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.35",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz",
+ "integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.35",
+ "@vue/runtime-core": "3.5.35",
+ "@vue/shared": "3.5.35",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.35",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.35.tgz",
+ "integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.35",
+ "@vue/shared": "3.5.35"
+ },
+ "peerDependencies": {
+ "vue": "3.5.35"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.35",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz",
+ "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==",
+ "license": "MIT"
+ },
+ "node_modules/alien-signals": {
+ "version": "1.0.13",
+ "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
+ "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
+ "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/de-indent": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
+ "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/hls.js": {
+ "version": "1.6.16",
+ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
+ "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+ "license": "MIT"
+ },
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
+ "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.4",
+ "@rollup/rollup-android-arm64": "4.60.4",
+ "@rollup/rollup-darwin-arm64": "4.60.4",
+ "@rollup/rollup-darwin-x64": "4.60.4",
+ "@rollup/rollup-freebsd-arm64": "4.60.4",
+ "@rollup/rollup-freebsd-x64": "4.60.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.4",
+ "@rollup/rollup-linux-arm64-musl": "4.60.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.4",
+ "@rollup/rollup-linux-loong64-musl": "4.60.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-musl": "4.60.4",
+ "@rollup/rollup-openbsd-x64": "4.60.4",
+ "@rollup/rollup-openharmony-arm64": "4.60.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.4",
+ "@rollup/rollup-win32-x64-gnu": "4.60.4",
+ "@rollup/rollup-win32-x64-msvc": "4.60.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
+ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
+ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vue": {
+ "version": "3.5.35",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz",
+ "integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.35",
+ "@vue/compiler-sfc": "3.5.35",
+ "@vue/runtime-dom": "3.5.35",
+ "@vue/server-renderer": "3.5.35",
+ "@vue/shared": "3.5.35"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-router": {
+ "version": "4.6.4",
+ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
+ "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/vue-tsc": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz",
+ "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/typescript": "2.4.15",
+ "@vue/language-core": "2.2.12"
+ },
+ "bin": {
+ "vue-tsc": "bin/vue-tsc.js"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..5eed3d71
--- /dev/null
+++ b/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "tvapp2",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vue-tsc -b && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "hls.js": "^1.6.16",
+ "mitt": "^3.0.1",
+ "vue": "^3.5.13",
+ "vue-router": "^4.5.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.2.1",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.5",
+ "vue-tsc": "^2.2.0"
+ }
+}
diff --git a/server/config.local.json b/server/config.local.json
new file mode 100644
index 00000000..4f151f75
--- /dev/null
+++ b/server/config.local.json
@@ -0,0 +1,5 @@
+{
+ "mongoUri": "mongodb://tvapp:tvapp@127.0.0.1:27017/tvapp2?authSource=admin",
+ "port": 3000,
+ "logLevel": "debug"
+}
diff --git a/server/dist/config.js b/server/dist/config.js
new file mode 100644
index 00000000..174dc582
--- /dev/null
+++ b/server/dist/config.js
@@ -0,0 +1,40 @@
+import { readFileSync, existsSync } from 'node:fs';
+import { resolve } from 'node:path';
+const VALID_SCHEMES = /^mongodb(\+srv)?:\/\//;
+function resolveConfigPath() {
+ const envPath = process.env.TVAPP2_CONFIG;
+ if (envPath) {
+ if (!existsSync(envPath)) {
+ throw new Error(`TVAPP2_CONFIG points to "${envPath}" but the file does not exist.`);
+ }
+ return envPath;
+ }
+ const localPath = resolve(process.cwd(), 'config.local.json');
+ if (existsSync(localPath))
+ return localPath;
+ throw new Error('No config file found. Set TVAPP2_CONFIG to the mounted config path, ' +
+ 'or create ./config.local.json for development.');
+}
+export function loadConfig() {
+ const path = resolveConfigPath();
+ const raw = readFileSync(path, 'utf8');
+ let parsed;
+ try {
+ parsed = JSON.parse(raw);
+ }
+ catch (err) {
+ throw new Error(`Config file at "${path}" is not valid JSON: ${err.message}`);
+ }
+ if (!parsed || typeof parsed !== 'object') {
+ throw new Error(`Config file at "${path}" must be a JSON object.`);
+ }
+ const obj = parsed;
+ const mongoUri = obj.mongoUri;
+ if (typeof mongoUri !== 'string' || !VALID_SCHEMES.test(mongoUri)) {
+ throw new Error(`Config "mongoUri" must be a string starting with mongodb:// or mongodb+srv:// (got ${JSON.stringify(mongoUri)}).`);
+ }
+ const port = typeof obj.port === 'number' ? obj.port : 3000;
+ const logLevel = typeof obj.logLevel === 'string' ? obj.logLevel : 'info';
+ return { mongoUri, port, logLevel };
+}
+//# sourceMappingURL=config.js.map
\ No newline at end of file
diff --git a/server/dist/config.js.map b/server/dist/config.js.map
new file mode 100644
index 00000000..62981124
--- /dev/null
+++ b/server/dist/config.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAQpC,MAAM,aAAa,GAAG,uBAAuB,CAAC;AAE9C,SAAS,iBAAiB;IACxB,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC;IAC1C,IAAI,OAAO,EAAE,CAAC;QACZ,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;YACzB,MAAM,IAAI,KAAK,CAAC,4BAA4B,OAAO,gCAAgC,CAAC,CAAC;QACvF,CAAC;QACD,OAAO,OAAO,CAAC;IACjB,CAAC;IACD,MAAM,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,mBAAmB,CAAC,CAAC;IAC9D,IAAI,UAAU,CAAC,SAAS,CAAC;QAAE,OAAO,SAAS,CAAC;IAE5C,MAAM,IAAI,KAAK,CACb,sEAAsE;QACpE,gDAAgD,CACnD,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,UAAU;IACxB,MAAM,IAAI,GAAG,iBAAiB,EAAE,CAAC;IACjC,MAAM,GAAG,GAAG,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACvC,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC3B,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CAAC,mBAAmB,IAAI,wBAAyB,GAAa,CAAC,OAAO,EAAE,CAAC,CAAC;IAC3F,CAAC;IAED,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;QAC1C,MAAM,IAAI,KAAK,CAAC,mBAAmB,IAAI,0BAA0B,CAAC,CAAC;IACrE,CAAC;IACD,MAAM,GAAG,GAAG,MAAiC,CAAC;IAE9C,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAQ,CAAC;IAC9B,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;QAClE,MAAM,IAAI,KAAK,CACb,sFAAsF,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,CACnH,CAAC;IACJ,CAAC;IAED,MAAM,IAAI,GAAG,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;IAC5D,MAAM,QAAQ,GAAG,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC;IAE1E,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;AACtC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/db.js b/server/dist/db.js
new file mode 100644
index 00000000..22cf85c8
--- /dev/null
+++ b/server/dist/db.js
@@ -0,0 +1,24 @@
+import mongoose from 'mongoose';
+export async function connect(uri) {
+ mongoose.connection.on('error', (err) => {
+ console.error('[mongo] connection error:', err.message);
+ });
+ mongoose.connection.on('disconnected', () => {
+ console.warn('[mongo] disconnected');
+ });
+ mongoose.connection.on('reconnected', () => {
+ console.info('[mongo] reconnected');
+ });
+ await mongoose.connect(uri, {
+ serverSelectionTimeoutMS: 5000,
+ maxPoolSize: 10,
+ });
+ console.info('[mongo] connected');
+}
+export async function disconnect() {
+ await mongoose.disconnect();
+}
+export function isConnected() {
+ return mongoose.connection.readyState === 1;
+}
+//# sourceMappingURL=db.js.map
\ No newline at end of file
diff --git a/server/dist/db.js.map b/server/dist/db.js.map
new file mode 100644
index 00000000..8d14693c
--- /dev/null
+++ b/server/dist/db.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,UAAU,CAAC;AAEhC,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,GAAW;IACvC,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE;QACtC,OAAO,CAAC,KAAK,CAAC,2BAA2B,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC;IAC1D,CAAC,CAAC,CAAC;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,cAAc,EAAE,GAAG,EAAE;QAC1C,OAAO,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC;IACvC,CAAC,CAAC,CAAC;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,aAAa,EAAE,GAAG,EAAE;QACzC,OAAO,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;IACtC,CAAC,CAAC,CAAC;IAEH,MAAM,QAAQ,CAAC,OAAO,CAAC,GAAG,EAAE;QAC1B,wBAAwB,EAAE,IAAI;QAC9B,WAAW,EAAE,EAAE;KAChB,CAAC,CAAC;IACH,OAAO,CAAC,IAAI,CAAC,mBAAmB,CAAC,CAAC;AACpC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,UAAU;IAC9B,MAAM,QAAQ,CAAC,UAAU,EAAE,CAAC;AAC9B,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,OAAO,QAAQ,CAAC,UAAU,CAAC,UAAU,KAAK,CAAC,CAAC;AAC9C,CAAC"}
\ No newline at end of file
diff --git a/server/dist/index.js b/server/dist/index.js
new file mode 100644
index 00000000..b7113527
--- /dev/null
+++ b/server/dist/index.js
@@ -0,0 +1,79 @@
+import { existsSync } from 'node:fs';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import express from 'express';
+import { loadConfig } from './config.js';
+import { connect, disconnect } from './db.js';
+import { healthRouter } from './routes/health.js';
+import { playlistsRouter } from './routes/playlists.js';
+import { epgSourcesRouter } from './routes/epgSources.js';
+import { channelsRouter } from './routes/channels.js';
+import { activeStreamsRouter } from './routes/activeStreams.js';
+import { customPlaylistsRouter } from './routes/customPlaylists.js';
+import { programsRouter } from './routes/programs.js';
+import { activityRouter } from './routes/activity.js';
+import { streamSessionsRouter } from './routes/streamSessions.js';
+import { sourcesRouter } from './routes/sources.js';
+import { bootInitSources } from './sources/seed.js';
+async function main() {
+ const config = loadConfig();
+ try {
+ await connect(config.mongoUri);
+ }
+ catch (err) {
+ console.error('[startup] failed to connect to mongo:', err.message);
+ process.exit(1);
+ }
+ // Ingest the established (Default) source playlists: guarantee each from its committed bundle
+ // (idempotent), then kick a non-blocking live sync. Runs in both Docker variants via this single
+ // boot path. A failure here must not prevent the API from serving.
+ try {
+ await bootInitSources();
+ }
+ catch (err) {
+ console.error('[startup] source init error (continuing):', err.message);
+ }
+ const app = express();
+ app.use(express.json());
+ app.use('/api/health', healthRouter);
+ app.use('/api/playlists', playlistsRouter);
+ app.use('/api/epg-sources', epgSourcesRouter);
+ app.use('/api/channels', channelsRouter);
+ app.use('/api/active-streams', activeStreamsRouter);
+ app.use('/api/custom-playlists', customPlaylistsRouter);
+ app.use('/api/epg-programs', programsRouter);
+ app.use('/api/activity', activityRouter);
+ app.use('/api/stream-sessions', streamSessionsRouter);
+ // Generic source API (manifest, stream proxy, status, sync/reset) — mounted at root since its
+ // paths span /api/sources and /api/v1.
+ app.use(sourcesRouter);
+ const here = dirname(fileURLToPath(import.meta.url));
+ const publicDir = resolve(here, '..', 'public');
+ if (existsSync(publicDir)) {
+ app.use(express.static(publicDir));
+ app.get(/^\/(?!api\/).*/, (_req, res) => {
+ res.sendFile(resolve(publicDir, 'index.html'));
+ });
+ console.info(`[http] serving SPA from ${publicDir}`);
+ }
+ app.use((err, _req, res, _next) => {
+ console.error('[api] error:', err.message);
+ res.status(500).json({ error: 'internal_error' });
+ });
+ const server = app.listen(config.port, () => {
+ console.info(`[api] listening on :${config.port}`);
+ });
+ const shutdown = async (signal) => {
+ console.info(`[shutdown] received ${signal}`);
+ server.close();
+ await disconnect();
+ process.exit(0);
+ };
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
+ process.on('SIGINT', () => void shutdown('SIGINT'));
+}
+main().catch((err) => {
+ console.error('[startup] fatal:', err);
+ process.exit(1);
+});
+//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/server/dist/index.js.map b/server/dist/index.js.map
new file mode 100644
index 00000000..e73ba841
--- /dev/null
+++ b/server/dist/index.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
\ No newline at end of file
diff --git a/server/dist/models/ActiveStream.js b/server/dist/models/ActiveStream.js
new file mode 100644
index 00000000..4da1647d
--- /dev/null
+++ b/server/dist/models/ActiveStream.js
@@ -0,0 +1,25 @@
+import { Schema, model } from 'mongoose';
+const ActiveStreamSchema = new Schema({
+ id: { type: String, required: true, unique: true, index: true },
+ channelId: { type: String, required: true, index: true },
+ status: { type: String, required: true },
+ uptime: { type: String, required: true },
+ uptimeMin: { type: Number, required: true },
+ viewers: { type: Number, required: true },
+ peakViewers: { type: Number, required: true },
+ bitrate: { type: Number, required: true },
+ targetBitrate: { type: Number, required: true },
+ codec: { type: String, required: true },
+ audio: { type: String, required: true },
+ container: { type: String, required: true },
+ resolution: { type: String, required: true },
+ fps: { type: Number, required: true },
+ sourceUrl: { type: String, required: true },
+ sourceHost: { type: String, required: true },
+ droppedFrames: { type: Number, required: true },
+ droppedRatio: { type: Number, required: true },
+ latency: { type: Number, required: true },
+ bandwidth: { type: Number, required: true },
+}, { versionKey: false });
+export const ActiveStream = model('ActiveStream', ActiveStreamSchema);
+//# sourceMappingURL=ActiveStream.js.map
\ No newline at end of file
diff --git a/server/dist/models/ActiveStream.js.map b/server/dist/models/ActiveStream.js.map
new file mode 100644
index 00000000..87b354c2
--- /dev/null
+++ b/server/dist/models/ActiveStream.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"ActiveStream.js","sourceRoot":"","sources":["../../src/models/ActiveStream.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,kBAAkB,GAAG,IAAI,MAAM,CACnC;IACE,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IAC/D,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IACxD,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACxC,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACxC,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC3C,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACzC,WAAW,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC7C,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACzC,aAAa,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC/C,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACvC,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACvC,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC3C,UAAU,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC5C,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACrC,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC3C,UAAU,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC5C,aAAa,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC/C,YAAY,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC9C,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACzC,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;CAC5C,EACD,EAAE,UAAU,EAAE,KAAK,EAAE,CACtB,CAAC;AAEF,MAAM,CAAC,MAAM,YAAY,GAAG,KAAK,CAAC,cAAc,EAAE,kBAAkB,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/models/Activity.js b/server/dist/models/Activity.js
new file mode 100644
index 00000000..6d49c106
--- /dev/null
+++ b/server/dist/models/Activity.js
@@ -0,0 +1,9 @@
+import { Schema, model } from 'mongoose';
+const ActivitySchema = new Schema({
+ when: { type: String, required: true },
+ icon: { type: String, required: true },
+ html: { type: String, required: true },
+ order: { type: Number, required: true, index: true },
+}, { versionKey: false });
+export const Activity = model('Activity', ActivitySchema);
+//# sourceMappingURL=Activity.js.map
\ No newline at end of file
diff --git a/server/dist/models/Activity.js.map b/server/dist/models/Activity.js.map
new file mode 100644
index 00000000..398b650a
--- /dev/null
+++ b/server/dist/models/Activity.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"Activity.js","sourceRoot":"","sources":["../../src/models/Activity.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,cAAc,GAAG,IAAI,MAAM,CAC/B;IACE,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACtC,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACtC,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACtC,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;CACrD,EACD,EAAE,UAAU,EAAE,KAAK,EAAE,CACtB,CAAC;AAEF,MAAM,CAAC,MAAM,QAAQ,GAAG,KAAK,CAAC,UAAU,EAAE,cAAc,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/models/Channel.js b/server/dist/models/Channel.js
new file mode 100644
index 00000000..7abf38e1
--- /dev/null
+++ b/server/dist/models/Channel.js
@@ -0,0 +1,18 @@
+import { Schema, model } from 'mongoose';
+const ChannelSchema = new Schema({
+ id: { type: String, required: true, unique: true, index: true },
+ tvg_name: { type: String, required: true },
+ group: { type: String, required: true },
+ channel: { type: Number, required: true },
+ tvg_id: { type: String, default: null },
+ state: { type: String, enum: ['active', 'disabled'], required: true },
+ epg: { type: String, enum: ['matched', 'unmatched'], required: true },
+ source: { type: String, required: true },
+ url: { type: String, required: true, unique: true },
+ status: { type: String, required: true },
+ res: { type: String, required: true },
+ logoColor: { type: String, required: true },
+ initials: { type: String, required: true },
+}, { versionKey: false });
+export const Channel = model('Channel', ChannelSchema);
+//# sourceMappingURL=Channel.js.map
\ No newline at end of file
diff --git a/server/dist/models/Channel.js.map b/server/dist/models/Channel.js.map
new file mode 100644
index 00000000..15db582b
--- /dev/null
+++ b/server/dist/models/Channel.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"Channel.js","sourceRoot":"","sources":["../../src/models/Channel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,aAAa,GAAG,IAAI,MAAM,CAC9B;IACE,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IAC/D,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC1C,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACvC,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACzC,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE;IACvC,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,QAAQ,EAAE,UAAU,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE;IACrE,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,SAAS,EAAE,WAAW,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE;IACrE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACxC,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;IACnD,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACxC,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACrC,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC3C,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;CAC3C,EACD,EAAE,UAAU,EAAE,KAAK,EAAE,CACtB,CAAC;AAEF,MAAM,CAAC,MAAM,OAAO,GAAG,KAAK,CAAC,SAAS,EAAE,aAAa,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/models/CustomPlaylist.js b/server/dist/models/CustomPlaylist.js
new file mode 100644
index 00000000..8a0eee73
--- /dev/null
+++ b/server/dist/models/CustomPlaylist.js
@@ -0,0 +1,10 @@
+import { Schema, model } from 'mongoose';
+const CustomPlaylistSchema = new Schema({
+ id: { type: String, required: true, unique: true, index: true },
+ name: { type: String, required: true },
+ slug: { type: String, required: true, index: true },
+ channels: { type: Number, required: true },
+ updated: { type: String, required: true },
+}, { versionKey: false });
+export const CustomPlaylist = model('CustomPlaylist', CustomPlaylistSchema);
+//# sourceMappingURL=CustomPlaylist.js.map
\ No newline at end of file
diff --git a/server/dist/models/CustomPlaylist.js.map b/server/dist/models/CustomPlaylist.js.map
new file mode 100644
index 00000000..f5a3fd22
--- /dev/null
+++ b/server/dist/models/CustomPlaylist.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"CustomPlaylist.js","sourceRoot":"","sources":["../../src/models/CustomPlaylist.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,oBAAoB,GAAG,IAAI,MAAM,CACrC;IACE,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IAC/D,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACtC,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IACnD,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC1C,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;CAC1C,EACD,EAAE,UAAU,EAAE,KAAK,EAAE,CACtB,CAAC;AAEF,MAAM,CAAC,MAAM,cAAc,GAAG,KAAK,CAAC,gBAAgB,EAAE,oBAAoB,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/models/EpgSource.js b/server/dist/models/EpgSource.js
new file mode 100644
index 00000000..b7e27b0d
--- /dev/null
+++ b/server/dist/models/EpgSource.js
@@ -0,0 +1,15 @@
+import { Schema, model } from 'mongoose';
+const EpgSourceSchema = new Schema({
+ id: { type: String, required: true, unique: true, index: true },
+ name: { type: String, required: true },
+ url: { type: String, required: true },
+ channels: { type: Number, required: true },
+ programs: { type: Number, required: true },
+ lastSync: { type: String, required: true },
+ status: { type: String, required: true },
+ auto: { type: Boolean, required: true },
+ interval: { type: String, required: true },
+ builtin: { type: Boolean },
+}, { versionKey: false });
+export const EpgSource = model('EpgSource', EpgSourceSchema);
+//# sourceMappingURL=EpgSource.js.map
\ No newline at end of file
diff --git a/server/dist/models/EpgSource.js.map b/server/dist/models/EpgSource.js.map
new file mode 100644
index 00000000..56ca7dae
--- /dev/null
+++ b/server/dist/models/EpgSource.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"EpgSource.js","sourceRoot":"","sources":["../../src/models/EpgSource.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,eAAe,GAAG,IAAI,MAAM,CAChC;IACE,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IAC/D,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACtC,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACrC,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC1C,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC1C,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC1C,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACxC,IAAI,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE;IACvC,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC1C,OAAO,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE;CAC3B,EACD,EAAE,UAAU,EAAE,KAAK,EAAE,CACtB,CAAC;AAEF,MAAM,CAAC,MAAM,SAAS,GAAG,KAAK,CAAC,WAAW,EAAE,eAAe,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/models/Playlist.js b/server/dist/models/Playlist.js
new file mode 100644
index 00000000..4d224015
--- /dev/null
+++ b/server/dist/models/Playlist.js
@@ -0,0 +1,18 @@
+import { Schema, model } from 'mongoose';
+const PlaylistSchema = new Schema({
+ id: { type: String, required: true, unique: true, index: true },
+ name: { type: String, required: true },
+ url: { type: String, required: true },
+ groups: { type: Number, required: true },
+ lastSync: { type: String, required: true },
+ status: { type: String, required: true },
+ auto: { type: Boolean, required: true },
+ interval: { type: String, required: true },
+ builtin: { type: Boolean },
+ // Set for the established (Default) source playlists (dulo/common/dlhd). When present, the
+ // playlist's channels live in the SourceChannel collection (queried by this `source`) instead
+ // of the legacy PlaylistChannel join. Unset for legacy/mock playlists.
+ source: { type: String, default: null, index: true },
+}, { versionKey: false });
+export const Playlist = model('Playlist', PlaylistSchema);
+//# sourceMappingURL=Playlist.js.map
\ No newline at end of file
diff --git a/server/dist/models/Playlist.js.map b/server/dist/models/Playlist.js.map
new file mode 100644
index 00000000..42918d97
--- /dev/null
+++ b/server/dist/models/Playlist.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"Playlist.js","sourceRoot":"","sources":["../../src/models/Playlist.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,cAAc,GAAG,IAAI,MAAM,CAC/B;IACE,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IAC/D,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACtC,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACrC,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACxC,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC1C,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACxC,IAAI,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE;IACvC,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC1C,OAAO,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE;IAC1B,2FAA2F;IAC3F,8FAA8F;IAC9F,uEAAuE;IACvE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;CACrD,EACD,EAAE,UAAU,EAAE,KAAK,EAAE,CACtB,CAAC;AAEF,MAAM,CAAC,MAAM,QAAQ,GAAG,KAAK,CAAC,UAAU,EAAE,cAAc,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/models/PlaylistChannel.js b/server/dist/models/PlaylistChannel.js
new file mode 100644
index 00000000..3cccb2a3
--- /dev/null
+++ b/server/dist/models/PlaylistChannel.js
@@ -0,0 +1,10 @@
+import { Schema, model } from 'mongoose';
+const PlaylistChannelSchema = new Schema({
+ playlistId: { type: String, required: true, index: true },
+ channelId: { type: String, required: true, index: true },
+ order: { type: Number, required: true },
+}, { versionKey: false });
+PlaylistChannelSchema.index({ playlistId: 1, channelId: 1 }, { unique: true });
+PlaylistChannelSchema.index({ playlistId: 1, order: 1 });
+export const PlaylistChannel = model('PlaylistChannel', PlaylistChannelSchema);
+//# sourceMappingURL=PlaylistChannel.js.map
\ No newline at end of file
diff --git a/server/dist/models/PlaylistChannel.js.map b/server/dist/models/PlaylistChannel.js.map
new file mode 100644
index 00000000..14eaab52
--- /dev/null
+++ b/server/dist/models/PlaylistChannel.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"PlaylistChannel.js","sourceRoot":"","sources":["../../src/models/PlaylistChannel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,qBAAqB,GAAG,IAAI,MAAM,CACtC;IACE,UAAU,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IACzD,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IACxD,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;CACxC,EACD,EAAE,UAAU,EAAE,KAAK,EAAE,CACtB,CAAC;AAEF,qBAAqB,CAAC,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;AAC/E,qBAAqB,CAAC,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC;AAEzD,MAAM,CAAC,MAAM,eAAe,GAAG,KAAK,CAAC,iBAAiB,EAAE,qBAAqB,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/models/Program.js b/server/dist/models/Program.js
new file mode 100644
index 00000000..a7c86a3b
--- /dev/null
+++ b/server/dist/models/Program.js
@@ -0,0 +1,11 @@
+import { Schema, model } from 'mongoose';
+const ProgramSchema = new Schema({
+ channelId: { type: String, required: true, index: true },
+ start: { type: Number, required: true },
+ end: { type: Number, required: true },
+ title: { type: String, required: true },
+ cat: { type: String, required: true },
+}, { versionKey: false });
+ProgramSchema.index({ channelId: 1, start: 1 });
+export const Program = model('Program', ProgramSchema);
+//# sourceMappingURL=Program.js.map
\ No newline at end of file
diff --git a/server/dist/models/Program.js.map b/server/dist/models/Program.js.map
new file mode 100644
index 00000000..ed431343
--- /dev/null
+++ b/server/dist/models/Program.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"Program.js","sourceRoot":"","sources":["../../src/models/Program.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,aAAa,GAAG,IAAI,MAAM,CAC9B;IACE,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IACxD,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACvC,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACrC,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACvC,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;CACtC,EACD,EAAE,UAAU,EAAE,KAAK,EAAE,CACtB,CAAC;AAEF,aAAa,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC;AAEhD,MAAM,CAAC,MAAM,OAAO,GAAG,KAAK,CAAC,SAAS,EAAE,aAAa,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/models/SourceChannel.js b/server/dist/models/SourceChannel.js
new file mode 100644
index 00000000..a3d66271
--- /dev/null
+++ b/server/dist/models/SourceChannel.js
@@ -0,0 +1,22 @@
+import { Schema, model } from 'mongoose';
+const SourceChannelSchema = new Schema({
+ _id: { type: String, required: true },
+ source: { type: String, required: true },
+ sourceChannelId: { type: String, required: true },
+ name: { type: String, required: true },
+ category: { type: String, default: null },
+ groupKey: { type: String, required: true },
+ groupLabel: { type: String, required: true },
+ logoUrl: { type: String, default: null },
+ streamEntryUrl: { type: String, required: true },
+ isPlayable: { type: Boolean, required: true },
+ sourceCreatedAt: { type: String, default: null },
+ sourceUpdatedAt: { type: String, default: null },
+ ingestedAt: { type: String, required: true },
+}, { versionKey: false });
+// Covers the per-source grouped/ordered listing query (source → groupKey → name).
+SourceChannelSchema.index({ source: 1, groupKey: 1, name: 1 });
+// Dead-channel / playable filtering per source.
+SourceChannelSchema.index({ source: 1, isPlayable: 1 });
+export const SourceChannel = model('SourceChannel', SourceChannelSchema);
+//# sourceMappingURL=SourceChannel.js.map
\ No newline at end of file
diff --git a/server/dist/models/SourceChannel.js.map b/server/dist/models/SourceChannel.js.map
new file mode 100644
index 00000000..cd4c444c
--- /dev/null
+++ b/server/dist/models/SourceChannel.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"SourceChannel.js","sourceRoot":"","sources":["../../src/models/SourceChannel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAwBzC,MAAM,mBAAmB,GAAG,IAAI,MAAM,CACpC;IACE,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACrC,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACxC,eAAe,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACjD,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACtC,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE;IACzC,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC1C,UAAU,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC5C,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE;IACxC,cAAc,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAChD,UAAU,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC7C,eAAe,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE;IAChD,eAAe,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE;IAChD,UAAU,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;CAC7C,EACD,EAAE,UAAU,EAAE,KAAK,EAAE,CACtB,CAAC;AAEF,kFAAkF;AAClF,mBAAmB,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC;AAC/D,gDAAgD;AAChD,mBAAmB,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,CAAC,CAAC;AAExD,MAAM,CAAC,MAAM,aAAa,GAAG,KAAK,CAAmB,eAAe,EAAE,mBAAmB,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/models/StreamSession.js b/server/dist/models/StreamSession.js
new file mode 100644
index 00000000..035c5e95
--- /dev/null
+++ b/server/dist/models/StreamSession.js
@@ -0,0 +1,11 @@
+import { Schema, model } from 'mongoose';
+const StreamSessionSchema = new Schema({
+ ip: { type: String, required: true },
+ region: { type: String, required: true },
+ client: { type: String, required: true },
+ joined: { type: String, required: true },
+ bitrate: { type: String, required: true },
+ order: { type: Number, required: true, index: true },
+}, { versionKey: false });
+export const StreamSession = model('StreamSession', StreamSessionSchema);
+//# sourceMappingURL=StreamSession.js.map
\ No newline at end of file
diff --git a/server/dist/models/StreamSession.js.map b/server/dist/models/StreamSession.js.map
new file mode 100644
index 00000000..531d9084
--- /dev/null
+++ b/server/dist/models/StreamSession.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"StreamSession.js","sourceRoot":"","sources":["../../src/models/StreamSession.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,mBAAmB,GAAG,IAAI,MAAM,CACpC;IACE,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACpC,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACxC,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACxC,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACxC,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACzC,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;CACrD,EACD,EAAE,UAAU,EAAE,KAAK,EAAE,CACtB,CAAC;AAEF,MAAM,CAAC,MAAM,aAAa,GAAG,KAAK,CAAC,eAAe,EAAE,mBAAmB,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/routes/activeStreams.js b/server/dist/routes/activeStreams.js
new file mode 100644
index 00000000..f4805881
--- /dev/null
+++ b/server/dist/routes/activeStreams.js
@@ -0,0 +1,13 @@
+import { Router } from 'express';
+import { ActiveStream } from '../models/ActiveStream.js';
+export const activeStreamsRouter = Router();
+activeStreamsRouter.get('/', async (_req, res, next) => {
+ try {
+ const docs = await ActiveStream.find({}, { _id: 0 }).lean();
+ res.json(docs);
+ }
+ catch (err) {
+ next(err);
+ }
+});
+//# sourceMappingURL=activeStreams.js.map
\ No newline at end of file
diff --git a/server/dist/routes/activeStreams.js.map b/server/dist/routes/activeStreams.js.map
new file mode 100644
index 00000000..afbe4c41
--- /dev/null
+++ b/server/dist/routes/activeStreams.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"activeStreams.js","sourceRoot":"","sources":["../../src/routes/activeStreams.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAEzD,MAAM,CAAC,MAAM,mBAAmB,GAAG,MAAM,EAAE,CAAC;AAE5C,mBAAmB,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IACrD,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QAC5D,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,GAAG,CAAC,CAAC;IACZ,CAAC;AACH,CAAC,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/routes/activity.js b/server/dist/routes/activity.js
new file mode 100644
index 00000000..7efab394
--- /dev/null
+++ b/server/dist/routes/activity.js
@@ -0,0 +1,13 @@
+import { Router } from 'express';
+import { Activity } from '../models/Activity.js';
+export const activityRouter = Router();
+activityRouter.get('/', async (_req, res, next) => {
+ try {
+ const docs = await Activity.find({}, { _id: 0, order: 0 }).sort({ order: 1 }).lean();
+ res.json(docs);
+ }
+ catch (err) {
+ next(err);
+ }
+});
+//# sourceMappingURL=activity.js.map
\ No newline at end of file
diff --git a/server/dist/routes/activity.js.map b/server/dist/routes/activity.js.map
new file mode 100644
index 00000000..bc02bcca
--- /dev/null
+++ b/server/dist/routes/activity.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"activity.js","sourceRoot":"","sources":["../../src/routes/activity.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAEjD,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,EAAE,CAAC;AAEvC,cAAc,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IAChD,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QACrF,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,GAAG,CAAC,CAAC;IACZ,CAAC;AACH,CAAC,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/routes/channels.js b/server/dist/routes/channels.js
new file mode 100644
index 00000000..8dec7038
--- /dev/null
+++ b/server/dist/routes/channels.js
@@ -0,0 +1,22 @@
+import { Router } from 'express';
+import { Channel } from '../models/Channel.js';
+import { SourceChannel } from '../models/SourceChannel.js';
+export const channelsRouter = Router();
+// GET /api/channels → legacy mock channels (drives the existing dashboard bootstrap)
+// GET /api/channels?source= → canonical normalized SourceChannel docs for a (Default) source
+// playlist (the d-combine "path forward" contract, served over Mongo)
+channelsRouter.get('/', async (req, res, next) => {
+ try {
+ const source = typeof req.query.source === 'string' ? req.query.source : null;
+ if (source) {
+ const docs = await SourceChannel.find({ source }).sort({ groupKey: 1, name: 1 }).lean();
+ return res.json(docs);
+ }
+ const docs = await Channel.find({}, { _id: 0 }).lean();
+ res.json(docs);
+ }
+ catch (err) {
+ next(err);
+ }
+});
+//# sourceMappingURL=channels.js.map
\ No newline at end of file
diff --git a/server/dist/routes/channels.js.map b/server/dist/routes/channels.js.map
new file mode 100644
index 00000000..54b8acbd
--- /dev/null
+++ b/server/dist/routes/channels.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"channels.js","sourceRoot":"","sources":["../../src/routes/channels.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAC/C,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAE3D,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,EAAE,CAAC;AAEvC,gGAAgG;AAChG,iGAAiG;AACjG,sGAAsG;AACtG,cAAc,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IAC/C,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,OAAO,GAAG,CAAC,KAAK,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;QAC9E,IAAI,MAAM,EAAE,CAAC;YACX,MAAM,IAAI,GAAG,MAAM,aAAa,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;YACxF,OAAO,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACxB,CAAC;QACD,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QACvD,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,GAAG,CAAC,CAAC;IACZ,CAAC;AACH,CAAC,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/routes/customPlaylists.js b/server/dist/routes/customPlaylists.js
new file mode 100644
index 00000000..089dcbae
--- /dev/null
+++ b/server/dist/routes/customPlaylists.js
@@ -0,0 +1,13 @@
+import { Router } from 'express';
+import { CustomPlaylist } from '../models/CustomPlaylist.js';
+export const customPlaylistsRouter = Router();
+customPlaylistsRouter.get('/', async (_req, res, next) => {
+ try {
+ const docs = await CustomPlaylist.find({}, { _id: 0 }).lean();
+ res.json(docs);
+ }
+ catch (err) {
+ next(err);
+ }
+});
+//# sourceMappingURL=customPlaylists.js.map
\ No newline at end of file
diff --git a/server/dist/routes/customPlaylists.js.map b/server/dist/routes/customPlaylists.js.map
new file mode 100644
index 00000000..b0c1c9c9
--- /dev/null
+++ b/server/dist/routes/customPlaylists.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"customPlaylists.js","sourceRoot":"","sources":["../../src/routes/customPlaylists.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAE7D,MAAM,CAAC,MAAM,qBAAqB,GAAG,MAAM,EAAE,CAAC;AAE9C,qBAAqB,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IACvD,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,cAAc,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QAC9D,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,GAAG,CAAC,CAAC;IACZ,CAAC;AACH,CAAC,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/routes/epgSources.js b/server/dist/routes/epgSources.js
new file mode 100644
index 00000000..cb9a952d
--- /dev/null
+++ b/server/dist/routes/epgSources.js
@@ -0,0 +1,13 @@
+import { Router } from 'express';
+import { EpgSource } from '../models/EpgSource.js';
+export const epgSourcesRouter = Router();
+epgSourcesRouter.get('/', async (_req, res, next) => {
+ try {
+ const docs = await EpgSource.find({}, { _id: 0 }).lean();
+ res.json(docs);
+ }
+ catch (err) {
+ next(err);
+ }
+});
+//# sourceMappingURL=epgSources.js.map
\ No newline at end of file
diff --git a/server/dist/routes/epgSources.js.map b/server/dist/routes/epgSources.js.map
new file mode 100644
index 00000000..b936d2d8
--- /dev/null
+++ b/server/dist/routes/epgSources.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"epgSources.js","sourceRoot":"","sources":["../../src/routes/epgSources.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAEnD,MAAM,CAAC,MAAM,gBAAgB,GAAG,MAAM,EAAE,CAAC;AAEzC,gBAAgB,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IAClD,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,SAAS,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QACzD,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,GAAG,CAAC,CAAC;IACZ,CAAC;AACH,CAAC,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/routes/health.js b/server/dist/routes/health.js
new file mode 100644
index 00000000..bd8dc5e8
--- /dev/null
+++ b/server/dist/routes/health.js
@@ -0,0 +1,7 @@
+import { Router } from 'express';
+import { isConnected } from '../db.js';
+export const healthRouter = Router();
+healthRouter.get('/', (_req, res) => {
+ res.json({ ok: true, mongo: isConnected() ? 'connected' : 'disconnected' });
+});
+//# sourceMappingURL=health.js.map
\ No newline at end of file
diff --git a/server/dist/routes/health.js.map b/server/dist/routes/health.js.map
new file mode 100644
index 00000000..fac6e75b
--- /dev/null
+++ b/server/dist/routes/health.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"health.js","sourceRoot":"","sources":["../../src/routes/health.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAEvC,MAAM,CAAC,MAAM,YAAY,GAAG,MAAM,EAAE,CAAC;AAErC,YAAY,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;IAClC,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,cAAc,EAAE,CAAC,CAAC;AAC9E,CAAC,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/routes/playlists.js b/server/dist/routes/playlists.js
new file mode 100644
index 00000000..71585964
--- /dev/null
+++ b/server/dist/routes/playlists.js
@@ -0,0 +1,106 @@
+import { Router } from 'express';
+import { Playlist } from '../models/Playlist.js';
+import { PlaylistChannel } from '../models/PlaylistChannel.js';
+import { Channel } from '../models/Channel.js';
+import { SourceChannel } from '../models/SourceChannel.js';
+import { toUiChannel } from '../sources/translate.js';
+export const playlistsRouter = Router();
+// Channel count comes from SourceChannel for (Default) source playlists, else the legacy join table.
+async function channelCountFor(doc) {
+ if (doc.source)
+ return SourceChannel.countDocuments({ source: doc.source });
+ return PlaylistChannel.countDocuments({ playlistId: doc.id });
+}
+playlistsRouter.get('/', async (_req, res, next) => {
+ try {
+ const docs = await Playlist.find({}, { _id: 0 }).lean();
+ const [legacyCounts, sourceCounts] = await Promise.all([
+ PlaylistChannel.aggregate([
+ { $group: { _id: '$playlistId', count: { $sum: 1 } } },
+ ]),
+ SourceChannel.aggregate([
+ { $group: { _id: '$source', count: { $sum: 1 } } },
+ ]),
+ ]);
+ const legacyById = new Map(legacyCounts.map((c) => [c._id, c.count]));
+ const sourceBySource = new Map(sourceCounts.map((c) => [c._id, c.count]));
+ res.json(docs.map((d) => ({
+ ...d,
+ channels: d.source ? sourceBySource.get(d.source) ?? 0 : legacyById.get(d.id) ?? 0,
+ })));
+ }
+ catch (err) {
+ next(err);
+ }
+});
+playlistsRouter.get('/:id', async (req, res, next) => {
+ try {
+ const doc = await Playlist.findOne({ id: req.params.id }, { _id: 0 }).lean();
+ if (!doc)
+ return res.status(404).json({ error: 'not_found' });
+ res.json({ ...doc, channels: await channelCountFor(doc) });
+ }
+ catch (err) {
+ next(err);
+ }
+});
+// List channels in a playlist. (Default) source playlists project the canonical SourceChannel docs
+// through the translation layer (UI shape, nulls for unmapped fields); legacy playlists use the join.
+playlistsRouter.get('/:id/channels', async (req, res, next) => {
+ try {
+ const playlist = await Playlist.findOne({ id: req.params.id }).lean();
+ if (!playlist)
+ return res.status(404).json({ error: 'not_found' });
+ if (playlist.source) {
+ const docs = await SourceChannel.find({ source: playlist.source })
+ .sort({ groupKey: 1, name: 1 })
+ .lean();
+ return res.json(docs.map((d, order) => ({ ...toUiChannel(d), order })));
+ }
+ const memberships = await PlaylistChannel.find({ playlistId: req.params.id }, { _id: 0 })
+ .sort({ order: 1 })
+ .lean();
+ const channelIds = memberships.map((m) => m.channelId);
+ const channels = await Channel.find({ id: { $in: channelIds } }, { _id: 0 }).lean();
+ const byId = new Map(channels.map((c) => [c.id, c]));
+ res.json(memberships
+ .map((m) => {
+ const ch = byId.get(m.channelId);
+ return ch ? { ...ch, order: m.order } : null;
+ })
+ .filter(Boolean));
+ }
+ catch (err) {
+ next(err);
+ }
+});
+// Add a channel to a (legacy) playlist (idempotent on the unique pair).
+playlistsRouter.post('/:id/channels', async (req, res, next) => {
+ try {
+ const { channelId, order } = req.body ?? {};
+ if (typeof channelId !== 'string' || typeof order !== 'number') {
+ return res.status(400).json({ error: 'channelId (string) and order (number) required' });
+ }
+ const doc = await PlaylistChannel.findOneAndUpdate({ playlistId: req.params.id, channelId }, { $set: { order } }, { upsert: true, new: true, projection: { _id: 0 } }).lean();
+ res.status(201).json(doc);
+ }
+ catch (err) {
+ next(err);
+ }
+});
+// Remove a channel from a (legacy) playlist.
+playlistsRouter.delete('/:id/channels/:channelId', async (req, res, next) => {
+ try {
+ const result = await PlaylistChannel.deleteOne({
+ playlistId: req.params.id,
+ channelId: req.params.channelId,
+ });
+ if (result.deletedCount === 0)
+ return res.status(404).json({ error: 'not_found' });
+ res.status(204).end();
+ }
+ catch (err) {
+ next(err);
+ }
+});
+//# sourceMappingURL=playlists.js.map
\ No newline at end of file
diff --git a/server/dist/routes/playlists.js.map b/server/dist/routes/playlists.js.map
new file mode 100644
index 00000000..44995a87
--- /dev/null
+++ b/server/dist/routes/playlists.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"playlists.js","sourceRoot":"","sources":["../../src/routes/playlists.ts"],"names":[],"mappings":"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"}
\ No newline at end of file
diff --git a/server/dist/routes/programs.js b/server/dist/routes/programs.js
new file mode 100644
index 00000000..34e579ae
--- /dev/null
+++ b/server/dist/routes/programs.js
@@ -0,0 +1,19 @@
+import { Router } from 'express';
+import { Program } from '../models/Program.js';
+export const programsRouter = Router();
+// All programs, grouped by channelId — matches the EPG_PROGRAMS shape the SPA expects.
+programsRouter.get('/', async (_req, res, next) => {
+ try {
+ const docs = await Program.find({}, { _id: 0 }).sort({ channelId: 1, start: 1 }).lean();
+ const grouped = {};
+ for (const d of docs) {
+ const list = grouped[d.channelId] ?? (grouped[d.channelId] = []);
+ list.push({ start: d.start, end: d.end, title: d.title, cat: d.cat });
+ }
+ res.json(grouped);
+ }
+ catch (err) {
+ next(err);
+ }
+});
+//# sourceMappingURL=programs.js.map
\ No newline at end of file
diff --git a/server/dist/routes/programs.js.map b/server/dist/routes/programs.js.map
new file mode 100644
index 00000000..808a478a
--- /dev/null
+++ b/server/dist/routes/programs.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"programs.js","sourceRoot":"","sources":["../../src/routes/programs.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAE/C,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,EAAE,CAAC;AAEvC,uFAAuF;AACvF,cAAc,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IAChD,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QACxF,MAAM,OAAO,GAAsF,EAAE,CAAC;QACtG,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;YACrB,MAAM,IAAI,GAAG,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,GAAG,EAAE,CAAC,CAAC;YACjE,IAAI,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC;QACxE,CAAC;QACD,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACpB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,GAAG,CAAC,CAAC;IACZ,CAAC;AACH,CAAC,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/routes/sources.js b/server/dist/routes/sources.js
new file mode 100644
index 00000000..10782950
--- /dev/null
+++ b/server/dist/routes/sources.js
@@ -0,0 +1,88 @@
+// Generic source RestAPI, ported from ../d-combine/server.mjs into TVApp2's Express stack. One router
+// serves every source by iterating the registry — adding a source needs zero route changes.
+//
+// GET /api/sources manifest (drives the SPA; one entry per registered source)
+// GET /api/sources/:id/status runtime provenance (dlhd: live mirror; null otherwise)
+// GET /api/sources/:id/metrics per-source proxy counters
+// POST /api/sources/:id/sync live refresh → upsert channels + Playlist sync metadata
+// POST /api/sources/:id/reset restore the committed bundle baseline
+// GET /api/v1/:source/* single stream proxy; the :source segment binds that source's
+// resolve+proxy behavior (createProxyHandler per adapter)
+//
+// Mounted at the app root (app.use(sourcesRouter)) because its paths span /api/sources, /api/v1, …
+import { Router } from 'express';
+import { SOURCES, getSource } from '../sources/registry.js';
+import { createProxyHandler } from '../sources/core/proxyHandler.js';
+import { createMetrics, snapshotOne } from '../sources/core/metrics.js';
+import { syncLive, resetFromBundle } from '../sources/seed.js';
+export const sourcesRouter = Router();
+// Build one proxy handler (+ metrics bag) per source once, then dispatch by the :source segment.
+const metricsById = new Map();
+const proxyHandlers = new Map();
+for (const adapter of SOURCES) {
+ const m = createMetrics();
+ metricsById.set(adapter.id, m);
+ proxyHandlers.set(adapter.id, createProxyHandler(adapter, m));
+}
+// ── Manifest ────────────────────────────────────────────────────────────────
+sourcesRouter.get('/api/sources', (_req, res) => {
+ res.json(SOURCES.map((s) => ({
+ id: s.id,
+ label: s.label,
+ grouping: s.grouping,
+ sourceUrl: `/api/channels?source=${s.id}`, // normalized catalog over Mongo
+ proxyPrefix: `/api/v1/${s.id}/`,
+ statusUrl: s.status ? `/api/sources/${s.id}/status` : null,
+ })));
+});
+// ── Per-source runtime status (dlhd mirror provenance; null for sources without one) ──
+sourcesRouter.get('/api/sources/:id/status', async (req, res, next) => {
+ try {
+ const adapter = getSource(req.params.id);
+ if (!adapter)
+ return res.status(404).json({ error: `Unknown source: ${req.params.id}` });
+ const status = adapter.status ? await adapter.status() : null;
+ res.json(status ?? null);
+ }
+ catch (err) {
+ next(err);
+ }
+});
+// ── Per-source proxy metrics ──────────────────────────────────────────────────
+sourcesRouter.get('/api/sources/:id/metrics', (req, res) => {
+ const m = metricsById.get(req.params.id);
+ if (!m)
+ return res.status(404).json({ error: `Unknown source: ${req.params.id}` });
+ res.json(snapshotOne(m));
+});
+// ── Live sync (refresh channels + Playlist sync metadata from upstream) ───────
+sourcesRouter.post('/api/sources/:id/sync', async (req, res, next) => {
+ try {
+ if (!getSource(req.params.id))
+ return res.status(404).json({ error: `Unknown source: ${req.params.id}` });
+ res.json(await syncLive(req.params.id));
+ }
+ catch (err) {
+ next(err);
+ }
+});
+// ── Reset to the committed bundle baseline ────────────────────────────────────
+sourcesRouter.post('/api/sources/:id/reset', async (req, res, next) => {
+ try {
+ if (!getSource(req.params.id))
+ return res.status(404).json({ error: `Unknown source: ${req.params.id}` });
+ res.json(await resetFromBundle(req.params.id));
+ }
+ catch (err) {
+ next(err);
+ }
+});
+// ── Single stream proxy API ───────────────────────────────────────────────────
+sourcesRouter.get('/api/v1/:source/*', (req, res) => {
+ const handler = proxyHandlers.get(req.params.source);
+ if (!handler) {
+ return res.status(404).type('text/plain').send(`Unknown source: ${req.params.source}`);
+ }
+ return handler(req, res, () => undefined);
+});
+//# sourceMappingURL=sources.js.map
\ No newline at end of file
diff --git a/server/dist/routes/sources.js.map b/server/dist/routes/sources.js.map
new file mode 100644
index 00000000..0bd7f3fb
--- /dev/null
+++ b/server/dist/routes/sources.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"sources.js","sourceRoot":"","sources":["../../src/routes/sources.ts"],"names":[],"mappings":"AAAA,sGAAsG;AACtG,4FAA4F;AAC5F,EAAE;AACF,+FAA+F;AAC/F,2FAA2F;AAC3F,8DAA8D;AAC9D,4FAA4F;AAC5F,0EAA0E;AAC1E,iGAAiG;AACjG,4FAA4F;AAC5F,EAAE;AACF,mGAAmG;AAEnG,OAAO,EAAE,MAAM,EAAuB,MAAM,SAAS,CAAC;AACtD,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAC5D,OAAO,EAAE,kBAAkB,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,EAAE,aAAa,EAAE,WAAW,EAAgB,MAAM,4BAA4B,CAAC;AACtF,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAE/D,MAAM,CAAC,MAAM,aAAa,GAAG,MAAM,EAAE,CAAC;AAEtC,iGAAiG;AACjG,MAAM,WAAW,GAAG,IAAI,GAAG,EAAmB,CAAC;AAC/C,MAAM,aAAa,GAAG,IAAI,GAAG,EAA0B,CAAC;AACxD,KAAK,MAAM,OAAO,IAAI,OAAO,EAAE,CAAC;IAC9B,MAAM,CAAC,GAAG,aAAa,EAAE,CAAC;IAC1B,WAAW,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;IAC/B,aAAa,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,EAAE,kBAAkB,CAAC,OAAO,EAAE,CAAC,CAAmB,CAAC,CAAC;AAClF,CAAC;AAED,+EAA+E;AAC/E,aAAa,CAAC,GAAG,CAAC,cAAc,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;IAC9C,GAAG,CAAC,IAAI,CACN,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QAClB,EAAE,EAAE,CAAC,CAAC,EAAE;QACR,KAAK,EAAE,CAAC,CAAC,KAAK;QACd,QAAQ,EAAE,CAAC,CAAC,QAAQ;QACpB,SAAS,EAAE,wBAAwB,CAAC,CAAC,EAAE,EAAE,EAAE,gCAAgC;QAC3E,WAAW,EAAE,WAAW,CAAC,CAAC,EAAE,GAAG;QAC/B,SAAS,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,gBAAgB,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI;KAC3D,CAAC,CAAC,CACJ,CAAC;AACJ,CAAC,CAAC,CAAC;AAEH,yFAAyF;AACzF,aAAa,CAAC,GAAG,CAAC,yBAAyB,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IACpE,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACzC,IAAI,CAAC,OAAO;YAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,mBAAmB,GAAG,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;QACzF,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;QAC9D,GAAG,CAAC,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,CAAC;IAC3B,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,GAAG,CAAC,CAAC;IACZ,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,iFAAiF;AACjF,aAAa,CAAC,GAAG,CAAC,0BAA0B,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IACzD,MAAM,CAAC,GAAG,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IACzC,IAAI,CAAC,CAAC;QAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,mBAAmB,GAAG,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;IACnF,GAAG,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC;AAC3B,CAAC,CAAC,CAAC;AAEH,iFAAiF;AACjF,aAAa,CAAC,IAAI,CAAC,uBAAuB,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IACnE,IAAI,CAAC;QACH,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;YAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,mBAAmB,GAAG,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;QAC1G,GAAG,CAAC,IAAI,CAAC,MAAM,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC;IAC1C,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,GAAG,CAAC,CAAC;IACZ,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,iFAAiF;AACjF,aAAa,CAAC,IAAI,CAAC,wBAAwB,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IACpE,IAAI,CAAC;QACH,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;YAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,mBAAmB,GAAG,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;QAC1G,GAAG,CAAC,IAAI,CAAC,MAAM,eAAe,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC;IACjD,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,GAAG,CAAC,CAAC;IACZ,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,iFAAiF;AACjF,aAAa,CAAC,GAAG,CAAC,mBAAmB,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAClD,MAAM,OAAO,GAAG,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACrD,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,IAAI,CAAC,mBAAmB,GAAG,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;IACzF,CAAC;IACD,OAAO,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;AAC5C,CAAC,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/routes/streamSessions.js b/server/dist/routes/streamSessions.js
new file mode 100644
index 00000000..9e219ac7
--- /dev/null
+++ b/server/dist/routes/streamSessions.js
@@ -0,0 +1,13 @@
+import { Router } from 'express';
+import { StreamSession } from '../models/StreamSession.js';
+export const streamSessionsRouter = Router();
+streamSessionsRouter.get('/', async (_req, res, next) => {
+ try {
+ const docs = await StreamSession.find({}, { _id: 0, order: 0 }).sort({ order: 1 }).lean();
+ res.json(docs);
+ }
+ catch (err) {
+ next(err);
+ }
+});
+//# sourceMappingURL=streamSessions.js.map
\ No newline at end of file
diff --git a/server/dist/routes/streamSessions.js.map b/server/dist/routes/streamSessions.js.map
new file mode 100644
index 00000000..c1b9b609
--- /dev/null
+++ b/server/dist/routes/streamSessions.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"streamSessions.js","sourceRoot":"","sources":["../../src/routes/streamSessions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAE3D,MAAM,CAAC,MAAM,oBAAoB,GAAG,MAAM,EAAE,CAAC;AAE7C,oBAAoB,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IACtD,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,aAAa,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QAC1F,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,GAAG,CAAC,CAAC;IACZ,CAAC;AACH,CAAC,CAAC,CAAC"}
\ No newline at end of file
diff --git a/server/dist/seed.js b/server/dist/seed.js
new file mode 100644
index 00000000..17011382
--- /dev/null
+++ b/server/dist/seed.js
@@ -0,0 +1,152 @@
+// Seed script — populates every collection from inlined mock data.
+// Idempotent: drops and refills each collection.
+//
+// cd server && npm run seed
+//
+// Uses the same config resolution as the API (TVAPP2_CONFIG or ./config.local.json).
+import { loadConfig } from './config.js';
+import { connect, disconnect } from './db.js';
+import { Playlist } from './models/Playlist.js';
+import { Channel } from './models/Channel.js';
+import { PlaylistChannel } from './models/PlaylistChannel.js';
+import { EpgSource } from './models/EpgSource.js';
+import { CustomPlaylist } from './models/CustomPlaylist.js';
+import { ActiveStream } from './models/ActiveStream.js';
+import { Program } from './models/Program.js';
+import { Activity } from './models/Activity.js';
+import { StreamSession } from './models/StreamSession.js';
+const PLAYLISTS = [
+ { id: 'pl-default', name: 'Default', url: 'bundled://tvapp2/default.m3u', groups: 6, lastSync: 'Ships with TVApp2', status: 'good', auto: true, interval: 'Auto-updated', builtin: true },
+ { id: 'pl-iptv-pro', name: 'IPTV-Pro Main', url: 'https://iptv-pro.example.com/playlist.m3u8', groups: 8, lastSync: '2 minutes ago', status: 'good', auto: true, interval: 'Every 6 hours' },
+ { id: 'pl-free-uk', name: 'Free UK Bouquet', url: 'https://iptv-org.github.io/iptv/countries/uk.m3u', groups: 4, lastSync: '1 hour ago', status: 'good', auto: true, interval: 'Daily' },
+ { id: 'pl-archive', name: 'Archive (legacy)', url: 'file:///playlists/archive-2023.m3u', groups: 3, lastSync: '3 days ago', status: 'warn', auto: false, interval: 'Manual' },
+];
+const EPG_SOURCES = [
+ { id: 'epg-default', name: 'Default', url: 'bundled://tvapp2/default.xml.gz', channels: 86, programs: 5240, lastSync: 'Ships with TVApp2', status: 'good', auto: true, interval: 'Auto-updated', builtin: true },
+ { id: 'epg-xmltv-uk', name: 'XMLTV UK Guide', url: 'https://epg.example.com/uk.xml.gz', channels: 124, programs: 8420, lastSync: '12 minutes ago', status: 'good', auto: true, interval: 'Every 12 hours' },
+ { id: 'epg-iptv-org', name: 'iptv-org world EPG', url: 'https://iptv-org.github.io/epg/guides/uk/openepg.xml', channels: 412, programs: 24180, lastSync: '2 hours ago', status: 'good', auto: true, interval: 'Daily' },
+];
+const CHANNEL_SEEDS = [
+ { tvg_name: 'BBC One HD', group: 'Entertainment', channel: 101, tvg_id: 'bbc.one.uk', state: 'active', epg: 'matched', status: 'good', res: '1080p' },
+ { tvg_name: 'BBC Two HD', group: 'Entertainment', channel: 102, tvg_id: 'bbc.two.uk', state: 'active', epg: 'matched', status: 'good', res: '1080p' },
+ { tvg_name: 'BBC News', group: 'News', channel: 231, tvg_id: 'bbc.news.uk', state: 'active', epg: 'matched', status: 'good', res: '720p' },
+ { tvg_name: 'Sky Sports Main', group: 'Sport', channel: 401, tvg_id: 'sky.sports.main.uk', state: 'active', epg: 'matched', status: 'good', res: '1080p' },
+ { tvg_name: 'Sky Sports F1', group: 'Sport', channel: 406, tvg_id: 'sky.sports.f1.uk', state: 'active', epg: 'matched', status: 'good', res: '1080p' },
+ { tvg_name: 'ITV1 HD', group: 'Entertainment', channel: 103, tvg_id: 'itv1.uk', state: 'active', epg: 'matched', status: 'good', res: '1080p' },
+ { tvg_name: 'Channel 4 HD', group: 'Entertainment', channel: 104, tvg_id: 'channel4.uk', state: 'active', epg: 'matched', status: 'warn', res: '1080p' },
+ { tvg_name: 'Film4', group: 'Movies', channel: 315, tvg_id: 'film4.uk', state: 'active', epg: 'matched', status: 'good', res: '720p' },
+ { tvg_name: 'Discovery Channel', group: 'Documentary', channel: 520, tvg_id: 'discovery.uk', state: 'active', epg: 'matched', status: 'good', res: '1080p' },
+ { tvg_name: 'National Geographic', group: 'Documentary', channel: 521, tvg_id: 'natgeo.uk', state: 'active', epg: 'unmatched', status: 'good', res: '1080p' },
+ { tvg_name: 'CNN International', group: 'News', channel: 233, tvg_id: 'cnn.int', state: 'active', epg: 'matched', status: 'good', res: '720p' },
+ { tvg_name: 'Al Jazeera English', group: 'News', channel: 235, tvg_id: 'aljazeera.en', state: 'active', epg: 'matched', status: 'good', res: '720p' },
+ { tvg_name: 'Cartoon Network', group: 'Kids', channel: 601, tvg_id: 'cartoonnet.uk', state: 'active', epg: 'matched', status: 'good', res: '720p' },
+ { tvg_name: 'Nick Jr.', group: 'Kids', channel: 615, tvg_id: null, state: 'disabled', epg: 'unmatched', status: 'warn', res: '720p' },
+ { tvg_name: 'MTV Hits', group: 'Music', channel: 365, tvg_id: 'mtv.hits.uk', state: 'active', epg: 'matched', status: 'good', res: '720p' },
+ { tvg_name: 'Kerrang!', group: 'Music', channel: 369, tvg_id: 'kerrang.uk', state: 'active', epg: 'matched', status: 'good', res: '720p' },
+ { tvg_name: 'Food Network', group: 'Lifestyle', channel: 240, tvg_id: 'foodnet.uk', state: 'active', epg: 'matched', status: 'good', res: '720p' },
+ { tvg_name: 'HGTV', group: 'Lifestyle', channel: 242, tvg_id: null, state: 'disabled', epg: 'unmatched', status: 'bad', res: '720p' },
+ { tvg_name: 'TCM Movies', group: 'Movies', channel: 320, tvg_id: 'tcm.uk', state: 'active', epg: 'matched', status: 'good', res: '1080p' },
+ { tvg_name: 'Eurosport 1', group: 'Sport', channel: 410, tvg_id: 'eurosport1.uk', state: 'active', epg: 'matched', status: 'good', res: '1080p' },
+];
+const CHANNELS = CHANNEL_SEEDS.map((c, i) => ({
+ id: `ch-${i}`,
+ ...c,
+ source: 'Default',
+ url: `http://sample.stream.com/channel/1.m3u8?ch=ch-${i}`,
+ logoColor: `oklch(0.5 0.16 ${(i * 47) % 360})`,
+ initials: c.tvg_name.split(/\s+/).slice(0, 2).map((w) => w[0]).join('').toUpperCase(),
+}));
+const CUSTOM_PLAYLISTS = [
+ { id: 'cust-sports-night', name: 'Sports Night', slug: 'sports-night', channels: 12, updated: '2 days ago' },
+ { id: 'cust-kids-safe', name: 'Kids Safe', slug: 'kids-safe', channels: 8, updated: 'yesterday' },
+ { id: 'cust-news-rotation', name: 'News Rotation', slug: 'news-rotation', channels: 6, updated: '5 hours ago' },
+ { id: 'cust-living-room', name: 'Living Room Favorites', slug: 'living-room', channels: 18, updated: '1 week ago' },
+];
+const ACTIVE_STREAMS = [
+ { id: 'as-1', channelId: 'ch-0', status: 'good', uptime: '4h 12m', uptimeMin: 252, viewers: 142, peakViewers: 168, bitrate: 6.4, targetBitrate: 6.0, codec: 'H.264 High@4.1', audio: 'AAC LC 2.0 · 128k', container: 'HLS / TS', resolution: '1920×1080', fps: 50, sourceUrl: 'http://stream.iptv-pro.example.com/live/bbc-one/index.m3u8', sourceHost: 'edge-fra-04', droppedFrames: 0, droppedRatio: 0.00, latency: 2.1, bandwidth: 912 },
+ { id: 'as-2', channelId: 'ch-3', status: 'good', uptime: '1h 48m', uptimeMin: 108, viewers: 89, peakViewers: 112, bitrate: 8.2, targetBitrate: 8.0, codec: 'H.264 High@4.2', audio: 'AC3 5.1 · 384k', container: 'HLS / fMP4', resolution: '1920×1080', fps: 50, sourceUrl: 'http://stream.iptv-pro.example.com/live/sky-sports-main/index.m3u8', sourceHost: 'edge-lon-02', droppedFrames: 14, droppedRatio: 0.01, latency: 1.8, bandwidth: 730 },
+ { id: 'as-3', channelId: 'ch-2', status: 'good', uptime: '22h 06m', uptimeMin: 1326, viewers: 47, peakViewers: 61, bitrate: 3.1, targetBitrate: 3.0, codec: 'H.264 Main@3.1', audio: 'AAC LC 2.0 · 96k', container: 'HLS / TS', resolution: '1280×720', fps: 25, sourceUrl: 'http://stream.iptv-pro.example.com/live/bbc-news/index.m3u8', sourceHost: 'edge-fra-04', droppedFrames: 2, droppedRatio: 0.00, latency: 2.4, bandwidth: 145 },
+ { id: 'as-4', channelId: 'ch-6', status: 'warn', uptime: '12m', uptimeMin: 12, viewers: 8, peakViewers: 8, bitrate: 4.1, targetBitrate: 5.0, codec: 'H.264 High@4.0', audio: 'AAC LC 2.0 · 128k', container: 'HLS / TS', resolution: '1920×1080', fps: 25, sourceUrl: 'http://stream.iptv-pro.example.com/live/channel4/index.m3u8', sourceHost: 'edge-ams-01', droppedFrames: 184, droppedRatio: 0.47, latency: 4.7, bandwidth: 34 },
+ { id: 'as-5', channelId: 'ch-4', status: 'good', uptime: '3h 41m', uptimeMin: 221, viewers: 31, peakViewers: 44, bitrate: 7.9, targetBitrate: 8.0, codec: 'H.264 High@4.2', audio: 'AC3 5.1 · 384k', container: 'HLS / fMP4', resolution: '1920×1080', fps: 50, sourceUrl: 'http://stream.iptv-pro.example.com/live/sky-sports-f1/index.m3u8', sourceHost: 'edge-lon-02', droppedFrames: 0, droppedRatio: 0.00, latency: 1.9, bandwidth: 245 },
+ { id: 'as-6', channelId: 'ch-8', status: 'good', uptime: '45m', uptimeMin: 45, viewers: 12, peakViewers: 12, bitrate: 5.6, targetBitrate: 6.0, codec: 'H.265 Main@4.0', audio: 'AAC LC 2.0 · 128k', container: 'HLS / fMP4', resolution: '1920×1080', fps: 25, sourceUrl: 'http://stream.iptv-pro.example.com/live/discovery/index.m3u8', sourceHost: 'edge-fra-04', droppedFrames: 1, droppedRatio: 0.00, latency: 2.2, bandwidth: 68 },
+ { id: 'as-7', channelId: 'ch-17', status: 'bad', uptime: '—', uptimeMin: 0, viewers: 0, peakViewers: 4, bitrate: 0, targetBitrate: 5.0, codec: '—', audio: '—', container: 'HLS / TS', resolution: '—', fps: 0, sourceUrl: 'http://stream.iptv-pro.example.com/live/hgtv/index.m3u8', sourceHost: 'edge-ams-01', droppedFrames: 0, droppedRatio: 0, latency: 0, bandwidth: 0 },
+];
+const ACTIVITY = [
+ { when: '2m', icon: 'sync', html: 'IPTV-Pro Main synced — 142 channels, no changes' },
+ { when: '12m', icon: 'epg', html: 'XMLTV UK Guide imported — 8,420 programs across 124 channels' },
+ { when: '1h', icon: 'map', html: 'Manual mapping: HGTV → hgtv.uk' },
+ { when: '1h', icon: 'warn', html: 'Free UK Bouquet reports 3 channels offline (HTTP 503)' },
+ { when: '3h', icon: 'edit', html: 'Renamed Discovery → Discovery Channel ' },
+ { when: 'Yest.', icon: 'add', html: 'Playlist IPTV-Pro Main added (142 channels)' },
+];
+const STREAM_SESSIONS = [
+ { ip: '82.14.221.47', region: 'GB · London', client: 'VLC / Linux', joined: '2m ago', bitrate: '6.4 Mbps' },
+ { ip: '192.81.45.12', region: 'DE · Frankfurt', client: 'Tivimate / Android TV', joined: '8m ago', bitrate: '6.4 Mbps' },
+ { ip: '104.18.92.5', region: 'NL · Amsterdam', client: 'OTT Navigator / FireTV', joined: '14m ago', bitrate: '3.1 Mbps' },
+ { ip: '176.58.103.9', region: 'GB · Manchester', client: 'Kodi 21', joined: '31m ago', bitrate: '6.4 Mbps' },
+ { ip: '78.143.211.4', region: 'FR · Paris', client: 'IPTV Smarters / iOS', joined: '1h ago', bitrate: '3.1 Mbps' },
+ { ip: '10.0.4.118', region: 'Local · LAN', client: 'ffmpeg / probe', joined: '3h ago', bitrate: '6.4 Mbps' },
+];
+const PROGRAM_LIBRARY = [
+ ['Morning News', 'Live'], ['Breakfast Show', 'Lifestyle'], ['Market Report', 'Business'],
+ ['Sports Roundup', 'Highlights'], ['Drama Hour', 'Series'], ['World Headlines', 'News'],
+ ['Wildlife Special', 'Documentary'], ['Cooking with Anna', 'Lifestyle'],
+ ['Classic Movies', 'Film'], ['Talk of the Day', 'Discussion'], ["Children's Hour", 'Kids'],
+ ['Weather Watch', 'Weather'], ['Live Match', 'Football'], ['Tech Today', 'Technology'],
+ ['Late Show', 'Comedy'], ['Documentary', 'Feature'], ['Music Mix', 'Music'],
+ ['Quiz Night', 'Game show'], ['Reality TV', 'Series'], ['The Daily Brief', 'News'],
+];
+function rngFor(seed) {
+ let s = seed;
+ return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
+}
+function generatePrograms(channelId, seedBase) {
+ const rng = rngFor(seedBase);
+ const progs = [];
+ let t = 0;
+ while (t < 24) {
+ const dur = [0.5, 1, 1, 1.5, 2][Math.floor(rng() * 5)];
+ const idx = Math.floor(rng() * PROGRAM_LIBRARY.length);
+ progs.push({ channelId, start: t, end: Math.min(24, t + dur), title: PROGRAM_LIBRARY[idx][0], cat: PROGRAM_LIBRARY[idx][1] });
+ t += dur;
+ }
+ return progs;
+}
+async function main() {
+ const config = loadConfig();
+ await connect(config.mongoUri);
+ await Promise.all([
+ Playlist.deleteMany({}),
+ Channel.deleteMany({}),
+ PlaylistChannel.deleteMany({}),
+ EpgSource.deleteMany({}),
+ CustomPlaylist.deleteMany({}),
+ ActiveStream.deleteMany({}),
+ Program.deleteMany({}),
+ Activity.deleteMany({}),
+ StreamSession.deleteMany({}),
+ ]);
+ await Playlist.insertMany(PLAYLISTS);
+ await Channel.insertMany(CHANNELS);
+ await EpgSource.insertMany(EPG_SOURCES);
+ await CustomPlaylist.insertMany(CUSTOM_PLAYLISTS);
+ await ActiveStream.insertMany(ACTIVE_STREAMS);
+ // Default playlist holds every channel in order.
+ const defaultPlaylist = PLAYLISTS.find((p) => p.builtin) ?? PLAYLISTS[0];
+ await PlaylistChannel.insertMany(CHANNELS.map((c, order) => ({ playlistId: defaultPlaylist.id, channelId: c.id, order })));
+ // EPG programs for the first 12 channels (matches the original mock).
+ const programs = CHANNELS.slice(0, 12).flatMap((c, i) => generatePrograms(c.id, 100 + i * 7));
+ await Program.insertMany(programs);
+ await Activity.insertMany(ACTIVITY.map((a, order) => ({ ...a, order })));
+ await StreamSession.insertMany(STREAM_SESSIONS.map((s, order) => ({ ...s, order })));
+ console.info(`[seed] playlists=${PLAYLISTS.length} channels=${CHANNELS.length} ` +
+ `epg-sources=${EPG_SOURCES.length} custom-playlists=${CUSTOM_PLAYLISTS.length} ` +
+ `active-streams=${ACTIVE_STREAMS.length} programs=${programs.length} ` +
+ `activity=${ACTIVITY.length} stream-sessions=${STREAM_SESSIONS.length}`);
+ await disconnect();
+}
+main().catch((err) => {
+ console.error('[seed] failed:', err);
+ process.exit(1);
+});
+//# sourceMappingURL=seed.js.map
\ No newline at end of file
diff --git a/server/dist/seed.js.map b/server/dist/seed.js.map
new file mode 100644
index 00000000..b2270a58
--- /dev/null
+++ b/server/dist/seed.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"seed.js","sourceRoot":"","sources":["../src/seed.ts"],"names":[],"mappings":"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"}
\ No newline at end of file
diff --git a/server/dist/sources/adapters/dulo.js b/server/dist/sources/adapters/dulo.js
new file mode 100644
index 00000000..aa2ab22b
--- /dev/null
+++ b/server/dist/sources/adapters/dulo.js
@@ -0,0 +1,116 @@
+// dulo.tv source adapter (Phase 1). Ported from ../d-combine/sources/dulo/adapter.mjs.
+//
+// dulo.tv exposes a JSON catalog API and each channel's `source_url` IS a token-free HLS master
+// playlist. The memfs hosts gate playback behind an Origin allowlist, so the proxy injects
+// `Origin: https://dulo.tv` on every hop. No server-side resolve is needed.
+import { readFileSync } from 'node:fs';
+import { snapshotFile } from '../paths.js';
+const SNAPSHOT = snapshotFile('dulo');
+const DULO_ORIGIN = 'https://dulo.tv';
+const DULO_API = process.env.DULO_API || 'https://dulo.tv/api/live-tv/channels';
+function isHttpUrl(url) {
+ if (typeof url !== 'string')
+ return false;
+ try {
+ const u = new URL(url);
+ return u.protocol === 'https:' || u.protocol === 'http:';
+ }
+ catch {
+ return false;
+ }
+}
+function toIso(ts) {
+ if (!ts || typeof ts !== 'string')
+ return null;
+ const d = new Date(ts);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+}
+const duloAdapter = {
+ id: 'dulo',
+ label: 'dulo',
+ // Prefer the live catalog API; fall back to the captured snapshot when offline / region-blocked.
+ async listChannels() {
+ try {
+ const res = await fetch(DULO_API, { headers: { Origin: DULO_ORIGIN } });
+ if (!res.ok)
+ throw new Error(`HTTP ${res.status}`);
+ const body = (await res.json());
+ const raw = body.channels || [];
+ if (!raw.length)
+ throw new Error('empty channel list');
+ return { raw, meta: { endpoint: DULO_API, live: true, fetchedAt: new Date().toISOString() } };
+ }
+ catch (err) {
+ const snap = JSON.parse(readFileSync(SNAPSHOT, 'utf8'));
+ return {
+ raw: snap.channels || [],
+ meta: {
+ endpoint: DULO_API,
+ live: false,
+ fallback: 'dulo.snapshot.json',
+ reason: err.message,
+ fetchedAt: new Date().toISOString(),
+ },
+ };
+ }
+ },
+ normalize(raw, { ingestedAt }) {
+ const sourceChannelId = String(raw.id);
+ const category = raw.category || null;
+ return {
+ _id: `dulo:${sourceChannelId}`,
+ source: 'dulo',
+ sourceChannelId,
+ name: raw.name,
+ category, // dulo has real semantic categories
+ groupKey: category || 'uncategorized',
+ groupLabel: category || 'uncategorized',
+ logoUrl: raw.logo_url || null,
+ streamEntryUrl: raw.source_url, // token-free master .m3u8 (handed straight to the proxy)
+ isPlayable: isHttpUrl(raw.source_url),
+ sourceCreatedAt: toIso(raw.created_at),
+ sourceUpdatedAt: toIso(raw.updated_at),
+ ingestedAt,
+ };
+ },
+ grouping: { by: 'groupKey', groupOrder: 'alpha', channelOrder: 'name' },
+ isEntryUrl() {
+ return false; // dulo source_url is already the master — nothing to resolve
+ },
+ async resolveStream(entryUrl) {
+ return { masterUrl: entryUrl }; // identity no-op
+ },
+ proxy: {
+ upstreamHeaders() {
+ return { Origin: DULO_ORIGIN }; // the memfs Origin allowlist gate
+ },
+ isAllowedUpstream(url) {
+ try {
+ const u = new URL(url);
+ return (u.protocol === 'https:' || u.protocol === 'http:') && u.hostname.endsWith('.dulo.tv');
+ }
+ catch {
+ return false;
+ }
+ },
+ onPlaylistChildHost: null, // static allowlist — nothing to learn at runtime
+ relabelSegmentContentType(_url, contentType) {
+ return contentType || 'application/octet-stream'; // plain TS — pass the upstream type through
+ },
+ classifyArtifact(url) {
+ try {
+ const p = new URL(url).pathname.toLowerCase();
+ if (p.endsWith('.ts'))
+ return 'segment';
+ if (p.endsWith('.m3u8'))
+ return p.includes('_output_') ? 'variant' : 'master';
+ return 'other';
+ }
+ catch {
+ return 'other';
+ }
+ },
+ },
+};
+export default duloAdapter;
+//# sourceMappingURL=dulo.js.map
\ No newline at end of file
diff --git a/server/dist/sources/adapters/dulo.js.map b/server/dist/sources/adapters/dulo.js.map
new file mode 100644
index 00000000..f49afb49
--- /dev/null
+++ b/server/dist/sources/adapters/dulo.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"dulo.js","sourceRoot":"","sources":["../../../src/sources/adapters/dulo.ts"],"names":[],"mappings":"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"}
\ No newline at end of file
diff --git a/server/dist/sources/core/buildSource.js b/server/dist/sources/core/buildSource.js
new file mode 100644
index 00000000..df155e8c
--- /dev/null
+++ b/server/dist/sources/core/buildSource.js
@@ -0,0 +1,39 @@
+// The generic "standard function" pipeline, run once per source for a LIVE sync:
+// listChannels() → raw upstream records → normalize(raw) → one SourceChannel doc each
+// → dedupe by deterministic _id (last wins, idempotent) → return docs for upsert.
+//
+// Ported from d-combine/lib/core/build-source.mjs, but returns the docs instead of writing files —
+// the seed module upserts them into Mongo. The whole file is source-agnostic.
+import { logger } from './logger.js';
+export async function buildSource(adapter) {
+ const startedAt = Date.now();
+ logger.info('build', `[${adapter.id}] fetching channel listings…`);
+ const { raw, meta = {} } = await adapter.listChannels();
+ logger.info('build', `[${adapter.id}] got ${raw.length} raw records (${meta.live === false ? 'offline snapshot' : 'live'})`);
+ const ingestedAt = new Date().toISOString();
+ const normalized = [];
+ for (const record of raw) {
+ try {
+ const doc = adapter.normalize(record, { ingestedAt });
+ if (doc)
+ normalized.push(doc);
+ }
+ catch (err) {
+ logger.warn('build', `[${adapter.id}] skipped a record: ${err.message}`);
+ }
+ }
+ // Dedupe by deterministic _id (last wins) — guards against duplicate upstream rows.
+ const byId = new Map();
+ for (const doc of normalized)
+ byId.set(doc._id, doc);
+ const docs = [...byId.values()];
+ logger.ok('build', `[${adapter.id}] normalized ${docs.length} docs`);
+ return {
+ id: adapter.id,
+ count: docs.length,
+ docs,
+ live: meta.live !== false,
+ meta: { ...meta, buildMs: Date.now() - startedAt },
+ };
+}
+//# sourceMappingURL=buildSource.js.map
\ No newline at end of file
diff --git a/server/dist/sources/core/buildSource.js.map b/server/dist/sources/core/buildSource.js.map
new file mode 100644
index 00000000..603e7407
--- /dev/null
+++ b/server/dist/sources/core/buildSource.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"buildSource.js","sourceRoot":"","sources":["../../../src/sources/core/buildSource.ts"],"names":[],"mappings":"AAAA,iFAAiF;AACjF,yFAAyF;AACzF,oFAAoF;AACpF,EAAE;AACF,mGAAmG;AACnG,8EAA8E;AAE9E,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAYrC,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,OAAsB;IACtD,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAC7B,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,OAAO,CAAC,EAAE,8BAA8B,CAAC,CAAC;IAEnE,MAAM,EAAE,GAAG,EAAE,IAAI,GAAG,EAAE,EAAE,GAAG,MAAM,OAAO,CAAC,YAAY,EAAE,CAAC;IACxD,MAAM,CAAC,IAAI,CACT,OAAO,EACP,IAAI,OAAO,CAAC,EAAE,SAAS,GAAG,CAAC,MAAM,iBAAiB,IAAI,CAAC,IAAI,KAAK,KAAK,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,MAAM,GAAG,CACvG,CAAC;IAEF,MAAM,UAAU,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAC5C,MAAM,UAAU,GAAuB,EAAE,CAAC;IAC1C,KAAK,MAAM,MAAM,IAAI,GAAG,EAAE,CAAC;QACzB,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,OAAO,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,UAAU,EAAE,CAAC,CAAC;YACtD,IAAI,GAAG;gBAAE,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAChC,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,OAAO,CAAC,EAAE,uBAAwB,GAAa,CAAC,OAAO,EAAE,CAAC,CAAC;QACtF,CAAC;IACH,CAAC;IAED,oFAAoF;IACpF,MAAM,IAAI,GAAG,IAAI,GAAG,EAA4B,CAAC;IACjD,KAAK,MAAM,GAAG,IAAI,UAAU;QAAE,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IACrD,MAAM,IAAI,GAAG,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;IAEhC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,IAAI,OAAO,CAAC,EAAE,gBAAgB,IAAI,CAAC,MAAM,OAAO,CAAC,CAAC;IACrE,OAAO;QACL,EAAE,EAAE,OAAO,CAAC,EAAE;QACd,KAAK,EAAE,IAAI,CAAC,MAAM;QAClB,IAAI;QACJ,IAAI,EAAE,IAAI,CAAC,IAAI,KAAK,KAAK;QACzB,IAAI,EAAE,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS,EAAE;KACnD,CAAC;AACJ,CAAC"}
\ No newline at end of file
diff --git a/server/dist/sources/core/logger.js b/server/dist/sources/core/logger.js
new file mode 100644
index 00000000..c6c95b34
--- /dev/null
+++ b/server/dist/sources/core/logger.js
@@ -0,0 +1,18 @@
+// Tiny tagged logger matching the existing console style ("[mongo] connected", "[api] …").
+// Ported from d-combine/lib/core/logger.mjs, trimmed to what the core uses.
+function emit(level, tag, msg) {
+ const line = `[${tag}] ${msg}`;
+ if (level === 'error')
+ console.error(line);
+ else if (level === 'warn')
+ console.warn(line);
+ else
+ console.info(line);
+}
+export const logger = {
+ info: (tag, msg) => emit('info', tag, msg),
+ warn: (tag, msg) => emit('warn', tag, msg),
+ error: (tag, msg) => emit('error', tag, msg),
+ ok: (tag, msg) => emit('ok', tag, msg),
+};
+//# sourceMappingURL=logger.js.map
\ No newline at end of file
diff --git a/server/dist/sources/core/logger.js.map b/server/dist/sources/core/logger.js.map
new file mode 100644
index 00000000..3a0666c3
--- /dev/null
+++ b/server/dist/sources/core/logger.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"logger.js","sourceRoot":"","sources":["../../../src/sources/core/logger.ts"],"names":[],"mappings":"AAAA,2FAA2F;AAC3F,4EAA4E;AAI5E,SAAS,IAAI,CAAC,KAAY,EAAE,GAAW,EAAE,GAAW;IAClD,MAAM,IAAI,GAAG,IAAI,GAAG,KAAK,GAAG,EAAE,CAAC;IAC/B,IAAI,KAAK,KAAK,OAAO;QAAE,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;SACtC,IAAI,KAAK,KAAK,MAAM;QAAE,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;;QACzC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC;AAED,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,IAAI,EAAE,CAAC,GAAW,EAAE,GAAW,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,CAAC;IAC1D,IAAI,EAAE,CAAC,GAAW,EAAE,GAAW,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,CAAC;IAC1D,KAAK,EAAE,CAAC,GAAW,EAAE,GAAW,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,GAAG,CAAC;IAC5D,EAAE,EAAE,CAAC,GAAW,EAAE,GAAW,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,EAAE,GAAG,CAAC;CACvD,CAAC"}
\ No newline at end of file
diff --git a/server/dist/sources/core/metrics.js b/server/dist/sources/core/metrics.js
new file mode 100644
index 00000000..f16b7c79
--- /dev/null
+++ b/server/dist/sources/core/metrics.js
@@ -0,0 +1,35 @@
+// In-memory counters, one set PER SOURCE. Surfaced via /api/sources/:id/status and /health-style
+// reporting. Ported from d-combine/lib/core/metrics.mjs.
+export function createMetrics() {
+ return {
+ startedAt: Date.now(),
+ requests: { total: 0, master: 0, variant: 0, segment: 0, other: 0, errors: 0 },
+ upstream: { ok: 0, notLive: 0, forbidden: 0, failed: 0 }, // 404=notLive, 403=forbidden(gate)
+ bytesStreamed: 0,
+ active: 0,
+ lastStreamAt: null,
+ lastError: null,
+ };
+}
+/** Human-readable byte size. */
+export function fmtBytes(n) {
+ if (n < 1024)
+ return `${n}B`;
+ if (n < 1048576)
+ return `${(n / 1024).toFixed(1)}KB`;
+ return `${(n / 1048576).toFixed(2)}MB`;
+}
+/** JSON snapshot of one source's metrics. */
+export function snapshotOne(m) {
+ return {
+ uptimeSeconds: Math.round((Date.now() - m.startedAt) / 1000),
+ active: m.active,
+ requests: { ...m.requests },
+ upstream: { ...m.upstream },
+ bytesStreamed: m.bytesStreamed,
+ mbStreamed: +(m.bytesStreamed / 1048576).toFixed(2),
+ lastStreamAt: m.lastStreamAt ? new Date(m.lastStreamAt).toISOString() : null,
+ lastError: m.lastError,
+ };
+}
+//# sourceMappingURL=metrics.js.map
\ No newline at end of file
diff --git a/server/dist/sources/core/metrics.js.map b/server/dist/sources/core/metrics.js.map
new file mode 100644
index 00000000..e05437b1
--- /dev/null
+++ b/server/dist/sources/core/metrics.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"metrics.js","sourceRoot":"","sources":["../../../src/sources/core/metrics.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,yDAAyD;AAmBzD,MAAM,UAAU,aAAa;IAC3B,OAAO;QACL,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE;QACrB,QAAQ,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE;QAC9E,QAAQ,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,EAAE,mCAAmC;QAC7F,aAAa,EAAE,CAAC;QAChB,MAAM,EAAE,CAAC;QACT,YAAY,EAAE,IAAI;QAClB,SAAS,EAAE,IAAI;KAChB,CAAC;AACJ,CAAC;AAED,gCAAgC;AAChC,MAAM,UAAU,QAAQ,CAAC,CAAS;IAChC,IAAI,CAAC,GAAG,IAAI;QAAE,OAAO,GAAG,CAAC,GAAG,CAAC;IAC7B,IAAI,CAAC,GAAG,OAAO;QAAE,OAAO,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;IACrD,OAAO,GAAG,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;AACzC,CAAC;AAED,6CAA6C;AAC7C,MAAM,UAAU,WAAW,CAAC,CAAU;IACpC,OAAO;QACL,aAAa,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,CAAC;QAC5D,MAAM,EAAE,CAAC,CAAC,MAAM;QAChB,QAAQ,EAAE,EAAE,GAAG,CAAC,CAAC,QAAQ,EAAE;QAC3B,QAAQ,EAAE,EAAE,GAAG,CAAC,CAAC,QAAQ,EAAE;QAC3B,aAAa,EAAE,CAAC,CAAC,aAAa;QAC9B,UAAU,EAAE,CAAC,CAAC,CAAC,CAAC,aAAa,GAAG,OAAO,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;QACnD,YAAY,EAAE,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,IAAI;QAC5E,SAAS,EAAE,CAAC,CAAC,SAAS;KACvB,CAAC;AACJ,CAAC"}
\ No newline at end of file
diff --git a/server/dist/sources/core/playlist.js b/server/dist/sources/core/playlist.js
new file mode 100644
index 00000000..5aa59c96
--- /dev/null
+++ b/server/dist/sources/core/playlist.js
@@ -0,0 +1,43 @@
+// Shared HLS playlist helpers. Ported from d-combine/lib/core/playlist.mjs. The only per-source
+// difference — whether the rewriter also learns (allowlists) each child host — is the `onChildHost`
+// hook param, so one implementation serves every source.
+/** True if the upstream URL / content-type looks like an HLS playlist (.m3u8). */
+export function looksLikePlaylist(upstreamUrl, contentType) {
+ if (contentType && contentType.includes('mpegurl'))
+ return true; // apple.mpegurl / x-mpegurl
+ try {
+ return new URL(upstreamUrl).pathname.toLowerCase().endsWith('.m3u8');
+ }
+ catch {
+ return false;
+ }
+}
+/**
+ * Rewrite every child URI in a playlist so it routes back through this proxy.
+ *
+ * @param text the raw playlist body
+ * @param baseUrl the upstream URL it was fetched from (for relative→absolute)
+ * @param prefix proxy mount prefix to prepend, e.g. "/api/v1/dulo/"
+ * @param onChildHost per-child-host hook (dlhd dynamic-allow; dulo/common null)
+ */
+export function rewritePlaylist(text, baseUrl, prefix, onChildHost) {
+ return text
+ .split(/\r?\n/)
+ .map((rawLine) => {
+ const trimmed = rawLine.trim();
+ if (!trimmed || trimmed.startsWith('#'))
+ return rawLine; // tag / comment / blank → as-is
+ const abs = new URL(trimmed, baseUrl).href; // resolve relative → absolute
+ if (onChildHost) {
+ try {
+ onChildHost(new URL(abs).hostname);
+ }
+ catch {
+ /* ignore malformed */
+ }
+ }
+ return `${prefix}${encodeURIComponent(abs)}`;
+ })
+ .join('\n');
+}
+//# sourceMappingURL=playlist.js.map
\ No newline at end of file
diff --git a/server/dist/sources/core/playlist.js.map b/server/dist/sources/core/playlist.js.map
new file mode 100644
index 00000000..e74b4748
--- /dev/null
+++ b/server/dist/sources/core/playlist.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"playlist.js","sourceRoot":"","sources":["../../../src/sources/core/playlist.ts"],"names":[],"mappings":"AAAA,gGAAgG;AAChG,oGAAoG;AACpG,yDAAyD;AAEzD,kFAAkF;AAClF,MAAM,UAAU,iBAAiB,CAAC,WAAmB,EAAE,WAAmB;IACxE,IAAI,WAAW,IAAI,WAAW,CAAC,QAAQ,CAAC,SAAS,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,4BAA4B;IAC7F,IAAI,CAAC;QACH,OAAO,IAAI,GAAG,CAAC,WAAW,CAAC,CAAC,QAAQ,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;IACvE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAC7B,IAAY,EACZ,OAAe,EACf,MAAc,EACd,WAA4C;IAE5C,OAAO,IAAI;SACR,KAAK,CAAC,OAAO,CAAC;SACd,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE;QACf,MAAM,OAAO,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC/B,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,OAAO,OAAO,CAAC,CAAC,gCAAgC;QACzF,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,8BAA8B;QAC1E,IAAI,WAAW,EAAE,CAAC;YAChB,IAAI,CAAC;gBACH,WAAW,CAAC,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC;YACrC,CAAC;YAAC,MAAM,CAAC;gBACP,sBAAsB;YACxB,CAAC;QACH,CAAC;QACD,OAAO,GAAG,MAAM,GAAG,kBAAkB,CAAC,GAAG,CAAC,EAAE,CAAC;IAC/C,CAAC,CAAC;SACD,IAAI,CAAC,IAAI,CAAC,CAAC;AAChB,CAAC"}
\ No newline at end of file
diff --git a/server/dist/sources/core/proxyHandler.js b/server/dist/sources/core/proxyHandler.js
new file mode 100644
index 00000000..02b4860d
--- /dev/null
+++ b/server/dist/sources/core/proxyHandler.js
@@ -0,0 +1,154 @@
+// One Express handler factory per source, bound to GET /api/v1/:source/*. Ported from
+// d-combine/lib/core/proxy-handler.mjs. Every per-source difference (resolve, headers, SSRF allow,
+// dynamic-allow, segment relabel, artifact classification) is read off `adapter`; the control flow
+// below is invariant.
+//
+// /api/v1//
+// · entry URL (dlhd watch.php / stream-N.php) → adapter.resolveStream() → fresh master, then proxy
+// · master/variant .m3u8 → rewrite child URIs back through /api/v1//…
+// · segment → pipe bytes (adapter may relabel the content-type)
+import { Readable } from 'node:stream';
+import { logger } from './logger.js';
+import { fmtBytes } from './metrics.js';
+import { looksLikePlaylist, rewritePlaylist } from './playlist.js';
+function label(url) {
+ try {
+ const u = new URL(url);
+ const file = u.pathname.split('/').pop() || '';
+ return { host: u.hostname, short: file.slice(0, 8) || '/' };
+ }
+ catch {
+ return { host: '?', short: '?' };
+ }
+}
+export function createProxyHandler(adapter, metrics) {
+ // Marker used to slice the raw (still-encoded) upstream URL out of req.originalUrl, independent of
+ // where the router is mounted. Keeps embedded ?session=/?md5&expires through ONE decodeURIComponent.
+ const MARKER = `/v1/${adapter.id}/`;
+ const PREFIX = `/api/v1/${adapter.id}/`;
+ const tag = `stream:${adapter.id}`;
+ const { proxy } = adapter;
+ return async function handler(req, res) {
+ const startedAt = Date.now();
+ const ms = () => `${Date.now() - startedAt}ms`;
+ // 1. Extract + decode the single percent-encoded upstream URL segment.
+ const idx = req.originalUrl.indexOf(MARKER);
+ const rawPath = idx >= 0 ? req.originalUrl.slice(idx + MARKER.length) : '';
+ let upstreamUrl;
+ try {
+ upstreamUrl = decodeURIComponent(rawPath);
+ }
+ catch {
+ logger.warn(tag, `400 malformed encoded URL from ${req.ip}`);
+ res.status(400).type('text/plain').send('Bad request: malformed encoded URL');
+ return;
+ }
+ // 2. Resolve-then-proxy: an entry URL must become a fresh stream URL first
+ // (dulo/common: never — entry IS the master; dlhd: the 3-hop scrape).
+ if (adapter.isEntryUrl(upstreamUrl)) {
+ metrics.requests.total++;
+ metrics.requests.master++;
+ try {
+ const resolved = await adapter.resolveStream(upstreamUrl);
+ upstreamUrl = resolved.masterUrl;
+ }
+ catch (err) {
+ metrics.requests.errors++;
+ metrics.upstream.notLive++;
+ metrics.lastError = err.message;
+ logger.warn(tag, `resolve failed: ${err.message} (${ms()})`);
+ res.status(502).type('text/plain').send(`Resolve failed: ${err.message}`);
+ return;
+ }
+ }
+ else {
+ // 3. Direct hop (master/variant/segment from a rewritten playlist) → SSRF gate.
+ if (!proxy.isAllowedUpstream(upstreamUrl)) {
+ logger.warn(tag, `400 blocked upstream: ${String(upstreamUrl).slice(0, 80)}`);
+ res.status(400).type('text/plain').send('Bad request: upstream host not in the allowlist');
+ return;
+ }
+ metrics.requests.total++;
+ metrics.requests[proxy.classifyArtifact(upstreamUrl)]++;
+ }
+ const type = proxy.classifyArtifact(upstreamUrl);
+ const { host, short } = label(upstreamUrl);
+ metrics.active++;
+ res.on('close', () => {
+ metrics.active--;
+ });
+ let upstream;
+ try {
+ upstream = await fetch(upstreamUrl, { headers: proxy.upstreamHeaders(upstreamUrl) });
+ }
+ catch (err) {
+ metrics.upstream.failed++;
+ metrics.requests.errors++;
+ metrics.lastError = err.message;
+ logger.error(tag, `${type} ${host} ${short} upstream fetch failed: ${err.message} (${ms()})`);
+ res.status(502).type('text/plain').send(`Upstream fetch failed: ${err.message}`);
+ return;
+ }
+ // Forward upstream errors verbatim. 404 = not transcoding right now; 403 = origin/referer gate.
+ if (!upstream.ok) {
+ metrics.requests.errors++;
+ if (upstream.status === 404)
+ metrics.upstream.notLive++;
+ else if (upstream.status === 403)
+ metrics.upstream.forbidden++;
+ else
+ metrics.upstream.failed++;
+ const note = upstream.status === 404 ? ' (not live)' : upstream.status === 403 ? ' (gate)' : '';
+ metrics.lastError = `HTTP ${upstream.status} ${host}/${short}`;
+ logger.warn(tag, `${type} ${host} ${short} status=${upstream.status}${note} (${ms()})`);
+ const detail = await upstream.text().catch(() => '');
+ res
+ .status(upstream.status)
+ .type(upstream.headers.get('content-type') || 'text/plain')
+ .send(detail || `Upstream HTTP ${upstream.status}`);
+ return;
+ }
+ const contentType = upstream.headers.get('content-type') || '';
+ // 4. Playlist → rewrite child URIs back through this source's proxy prefix
+ // (and let the adapter learn each child host: dlhd dynamic-allow; dulo/common no-op).
+ if (looksLikePlaylist(upstreamUrl, contentType)) {
+ const rewritten = rewritePlaylist(await upstream.text(), upstreamUrl, PREFIX, proxy.onPlaylistChildHost);
+ const bytes = Buffer.byteLength(rewritten);
+ metrics.upstream.ok++;
+ metrics.bytesStreamed += bytes;
+ metrics.lastStreamAt = Date.now();
+ logger.ok(tag, `${type} ${host} ${short} status=200 ${fmtBytes(bytes)} (${ms()})`);
+ res.set('Cache-Control', 'no-store'); // playlists + tokens are short-lived
+ res.type('application/vnd.apple.mpegurl').send(rewritten);
+ return;
+ }
+ // 5. Segment (or anything else) → stream the bytes through, content-type per the adapter.
+ res.set('Content-Type', proxy.relabelSegmentContentType(upstreamUrl, contentType, type));
+ res.set('Cache-Control', 'no-store');
+ if (!upstream.body) {
+ metrics.upstream.ok++;
+ logger.ok(tag, `${type} ${host} ${short} status=200 0B (${ms()})`);
+ res.end();
+ return;
+ }
+ let bytes = 0;
+ const body = Readable.fromWeb(upstream.body);
+ body.on('data', (chunk) => {
+ bytes += chunk.length;
+ });
+ res.on('finish', () => {
+ metrics.upstream.ok++;
+ metrics.bytesStreamed += bytes;
+ metrics.lastStreamAt = Date.now();
+ logger.ok(tag, `${type} ${host} ${short} status=200 ${fmtBytes(bytes)} (${ms()})`);
+ });
+ body.on('error', (err) => {
+ metrics.upstream.failed++;
+ metrics.lastError = err.message;
+ logger.error(tag, `${type} ${host} ${short} stream error: ${err.message}`);
+ res.destroy(err);
+ });
+ body.pipe(res);
+ };
+}
+//# sourceMappingURL=proxyHandler.js.map
\ No newline at end of file
diff --git a/server/dist/sources/core/proxyHandler.js.map b/server/dist/sources/core/proxyHandler.js.map
new file mode 100644
index 00000000..0be1c8ce
--- /dev/null
+++ b/server/dist/sources/core/proxyHandler.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"proxyHandler.js","sourceRoot":"","sources":["../../../src/sources/core/proxyHandler.ts"],"names":[],"mappings":"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"}
\ No newline at end of file
diff --git a/server/dist/sources/paths.js b/server/dist/sources/paths.js
new file mode 100644
index 00000000..22ee05cb
--- /dev/null
+++ b/server/dist/sources/paths.js
@@ -0,0 +1,16 @@
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+// This module lives at /sources/paths.{ts,js} in BOTH dev (server/src) and prod (server/dist).
+// The bundled seed assets sit at server/seed-data/sources — i.e. two levels up from here, then
+// seed-data/sources. The Docker runtime stage copies server/seed-data → /app/seed-data alongside
+// /app/dist, so this same relative resolution holds in the container.
+const here = dirname(fileURLToPath(import.meta.url));
+/** Directory holding the committed .source.json baselines + .snapshot.json fallbacks. */
+export const SEED_SOURCES_DIR = resolve(here, '..', '..', 'seed-data', 'sources');
+export function bundleFile(sourceId) {
+ return resolve(SEED_SOURCES_DIR, `${sourceId}.source.json`);
+}
+export function snapshotFile(sourceId) {
+ return resolve(SEED_SOURCES_DIR, `${sourceId}.snapshot.json`);
+}
+//# sourceMappingURL=paths.js.map
\ No newline at end of file
diff --git a/server/dist/sources/paths.js.map b/server/dist/sources/paths.js.map
new file mode 100644
index 00000000..763e4f87
--- /dev/null
+++ b/server/dist/sources/paths.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"paths.js","sourceRoot":"","sources":["../../src/sources/paths.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,qGAAqG;AACrG,+FAA+F;AAC/F,iGAAiG;AACjG,sEAAsE;AACtE,MAAM,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAErD,iGAAiG;AACjG,MAAM,CAAC,MAAM,gBAAgB,GAAG,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,SAAS,CAAC,CAAC;AAElF,MAAM,UAAU,UAAU,CAAC,QAAgB;IACzC,OAAO,OAAO,CAAC,gBAAgB,EAAE,GAAG,QAAQ,cAAc,CAAC,CAAC;AAC9D,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,QAAgB;IAC3C,OAAO,OAAO,CAAC,gBAAgB,EAAE,GAAG,QAAQ,gBAAgB,CAAC,CAAC;AAChE,CAAC"}
\ No newline at end of file
diff --git a/server/dist/sources/registry.js b/server/dist/sources/registry.js
new file mode 100644
index 00000000..19a9c5ef
--- /dev/null
+++ b/server/dist/sources/registry.js
@@ -0,0 +1,10 @@
+// The single enumeration of source adapters TVApp2 knows about. Ported from
+// ../d-combine/sources/registry.mjs. Adding a source (Phase 2: common, Phase 3: dlhd) = write a new
+// adapter under adapters/ and add it here; the boot init, sources router (manifest + proxy mounts),
+// and SPA all iterate this list, so nothing else needs to change.
+import duloAdapter from './adapters/dulo.js';
+export const SOURCES = [duloAdapter];
+export function getSource(id) {
+ return SOURCES.find((s) => s.id === id);
+}
+//# sourceMappingURL=registry.js.map
\ No newline at end of file
diff --git a/server/dist/sources/registry.js.map b/server/dist/sources/registry.js.map
new file mode 100644
index 00000000..d553929b
--- /dev/null
+++ b/server/dist/sources/registry.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/sources/registry.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,oGAAoG;AACpG,oGAAoG;AACpG,kEAAkE;AAElE,OAAO,WAAW,MAAM,oBAAoB,CAAC;AAG7C,MAAM,CAAC,MAAM,OAAO,GAAoB,CAAC,WAAW,CAAC,CAAC;AAEtD,MAAM,UAAU,SAAS,CAAC,EAAU;IAClC,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;AAC1C,CAAC"}
\ No newline at end of file
diff --git a/server/dist/sources/seed.js b/server/dist/sources/seed.js
new file mode 100644
index 00000000..8d0f43ed
--- /dev/null
+++ b/server/dist/sources/seed.js
@@ -0,0 +1,137 @@
+// Seed / init / sync / reset for the established (Default) source playlists.
+//
+// "Both: bundle + live sync" — the committed .source.json bundle is the GUARANTEED baseline used
+// to initialize/reset MongoDB (offline-safe, reproducible); a live sync then refreshes channels and
+// the Playlist's sync metadata from upstream when reachable. Boot init (ensureSeeded + background
+// syncLive via bootInitSources) runs once per source in server/src/index.ts after the Mongo connect,
+// covering both Docker variants without extra orchestration.
+import { readFileSync } from 'node:fs';
+import { Playlist } from '../models/Playlist.js';
+import { SourceChannel } from '../models/SourceChannel.js';
+import { SOURCES, getSource } from './registry.js';
+import { buildSource } from './core/buildSource.js';
+import { bundleFile } from './paths.js';
+import { logger } from './core/logger.js';
+function readBundle(id) {
+ const arr = JSON.parse(readFileSync(bundleFile(id), 'utf8'));
+ if (!Array.isArray(arr))
+ throw new Error(`bundle for "${id}" is not a JSON array`);
+ return arr;
+}
+function groupCount(docs) {
+ return new Set(docs.map((d) => d.groupKey)).size;
+}
+// Idempotent upsert by _id. Strips _id from $set (immutable; supplied by the filter on insert).
+async function upsertChannels(docs) {
+ if (!docs.length)
+ return;
+ const ops = docs.map((d) => {
+ const { _id, ...rest } = d;
+ return { updateOne: { filter: { _id }, update: { $set: rest }, upsert: true } };
+ });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await SourceChannel.bulkWrite(ops, { ordered: false });
+}
+async function upsertPlaylistRow(adapter, groups, opts) {
+ const row = {
+ id: adapter.id,
+ source: adapter.id,
+ name: `(Default) ${adapter.label}`,
+ url: `source://${adapter.id}`,
+ groups,
+ lastSync: opts.lastSync,
+ status: opts.status,
+ auto: true,
+ interval: 'Auto-updated',
+ builtin: true,
+ };
+ await Playlist.updateOne({ id: adapter.id }, { $set: row }, { upsert: true });
+}
+export async function validateIntegrity(id) {
+ const issues = [];
+ const playlist = await Playlist.findOne({ id }).lean();
+ const channelCount = await SourceChannel.countDocuments({ source: id });
+ if (!playlist)
+ issues.push('playlist row missing');
+ if (channelCount === 0)
+ issues.push('no channels');
+ const sample = await SourceChannel.findOne({ source: id }).lean();
+ if (sample) {
+ for (const f of ['name', 'streamEntryUrl', 'groupKey']) {
+ if (!sample[f])
+ issues.push(`a channel is missing required field "${f}"`);
+ }
+ }
+ return { id, playlistExists: !!playlist, channelCount, ok: issues.length === 0, issues };
+}
+/** Initialize a source from its committed bundle (idempotent upsert). */
+export async function initFromBundle(id) {
+ const adapter = getSource(id);
+ if (!adapter)
+ throw new Error(`unknown source: ${id}`);
+ const docs = readBundle(id);
+ await upsertChannels(docs);
+ await upsertPlaylistRow(adapter, groupCount(docs), { lastSync: 'Ships with TVApp2', status: 'good' });
+ logger.ok('seed', `[${id}] initialized ${docs.length} channels from bundle`);
+ return validateIntegrity(id);
+}
+/** Restore a source EXACTLY to its committed bundle (drops stale channels, then reinserts). */
+export async function resetFromBundle(id) {
+ const adapter = getSource(id);
+ if (!adapter)
+ throw new Error(`unknown source: ${id}`);
+ const docs = readBundle(id);
+ await SourceChannel.deleteMany({ source: id });
+ await SourceChannel.insertMany(docs, { ordered: false });
+ await upsertPlaylistRow(adapter, groupCount(docs), { lastSync: 'Reset from bundle', status: 'good' });
+ logger.ok('seed', `[${id}] reset ${docs.length} channels from bundle`);
+ return validateIntegrity(id);
+}
+/** Live refresh: run the adapter's build pipeline and upsert; updates Playlist sync metadata. */
+export async function syncLive(id) {
+ const adapter = getSource(id);
+ if (!adapter)
+ throw new Error(`unknown source: ${id}`);
+ const result = await buildSource(adapter);
+ await upsertChannels(result.docs);
+ await upsertPlaylistRow(adapter, groupCount(result.docs), {
+ lastSync: new Date().toISOString(),
+ status: result.live ? 'good' : 'warn',
+ });
+ logger.ok('seed', `[${id}] live sync upserted ${result.count} channels (${result.live ? 'live' : 'snapshot'})`);
+ const report = await validateIntegrity(id);
+ return { report, live: result.live, count: result.count };
+}
+/** Boot guard: seed from bundle only if the (Default) playlist is missing or empty. */
+export async function ensureSeeded(id) {
+ const report = await validateIntegrity(id);
+ if (report.playlistExists && report.channelCount > 0) {
+ logger.info('seed', `[${id}] already seeded (${report.channelCount} channels) — skipping init`);
+ return report;
+ }
+ logger.info('seed', `[${id}] not seeded — initializing from bundle`);
+ return initFromBundle(id);
+}
+/**
+ * Run once at startup for every registered source: guarantee the bundle baseline synchronously, then
+ * kick a non-blocking live sync (Both mode). A failed/slow live sync must NEVER block or crash boot —
+ * the bundle baseline already satisfies init.
+ */
+export async function bootInitSources(opts = {}) {
+ const liveSync = opts.liveSync ?? true;
+ for (const adapter of SOURCES) {
+ try {
+ const report = await ensureSeeded(adapter.id);
+ if (!report.ok) {
+ logger.warn('seed', `[${adapter.id}] integrity issues after init: ${report.issues.join(', ')}`);
+ }
+ if (liveSync) {
+ void syncLive(adapter.id).catch((err) => logger.warn('seed', `[${adapter.id}] live sync failed (keeping bundle baseline): ${err.message}`));
+ }
+ }
+ catch (err) {
+ logger.error('seed', `[${adapter.id}] init failed: ${err.message}`);
+ }
+ }
+}
+//# sourceMappingURL=seed.js.map
\ No newline at end of file
diff --git a/server/dist/sources/seed.js.map b/server/dist/sources/seed.js.map
new file mode 100644
index 00000000..01937ee8
--- /dev/null
+++ b/server/dist/sources/seed.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"seed.js","sourceRoot":"","sources":["../../src/sources/seed.ts"],"names":[],"mappings":"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"}
\ No newline at end of file
diff --git a/server/dist/sources/translate.js b/server/dist/sources/translate.js
new file mode 100644
index 00000000..a323ac2f
--- /dev/null
+++ b/server/dist/sources/translate.js
@@ -0,0 +1,46 @@
+// Translation layer: project a canonical SourceChannel doc into the legacy UI Channel shape the Vue
+// screens consume. Fields with no source equivalent are returned as explicit null (never fabricated)
+// per the agreed schema-reconciliation decision; logoUrl + streamEntryUrl + isPlayable are added so
+// the SPA can render real logos and play through the proxy. The frontend derives the proxy path from
+// (source, streamEntryUrl); it is intentionally not stored.
+// Deterministic hue from a stable string → keeps a channel's fallback logo color stable across syncs.
+function hueFromString(s) {
+ let h = 0;
+ for (let i = 0; i < s.length; i++)
+ h = (h * 31 + s.charCodeAt(i)) >>> 0;
+ return h % 360;
+}
+function logoColorFor(id) {
+ return `oklch(0.5 0.16 ${hueFromString(id)})`;
+}
+function initialsFor(name) {
+ const ini = name
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((w) => w[0])
+ .join('')
+ .toUpperCase();
+ return ini || '?';
+}
+export function toUiChannel(doc) {
+ return {
+ id: doc._id,
+ tvg_name: doc.name,
+ group: doc.groupLabel,
+ channel: null,
+ tvg_id: null,
+ state: doc.isPlayable ? 'active' : 'disabled',
+ epg: null,
+ status: null,
+ res: null,
+ source: doc.source,
+ url: doc.streamEntryUrl,
+ logoColor: logoColorFor(doc._id),
+ initials: initialsFor(doc.name),
+ logoUrl: doc.logoUrl,
+ streamEntryUrl: doc.streamEntryUrl,
+ isPlayable: doc.isPlayable,
+ };
+}
+//# sourceMappingURL=translate.js.map
\ No newline at end of file
diff --git a/server/dist/sources/translate.js.map b/server/dist/sources/translate.js.map
new file mode 100644
index 00000000..e253537b
--- /dev/null
+++ b/server/dist/sources/translate.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"translate.js","sourceRoot":"","sources":["../../src/sources/translate.ts"],"names":[],"mappings":"AAAA,oGAAoG;AACpG,qGAAqG;AACrG,oGAAoG;AACpG,qGAAqG;AACrG,4DAA4D;AAuB5D,sGAAsG;AACtG,SAAS,aAAa,CAAC,CAAS;IAC9B,IAAI,CAAC,GAAG,CAAC,CAAC;IACV,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE;QAAE,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IACxE,OAAO,CAAC,GAAG,GAAG,CAAC;AACjB,CAAC;AAED,SAAS,YAAY,CAAC,EAAU;IAC9B,OAAO,kBAAkB,aAAa,CAAC,EAAE,CAAC,GAAG,CAAC;AAChD,CAAC;AAED,SAAS,WAAW,CAAC,IAAY;IAC/B,MAAM,GAAG,GAAG,IAAI;SACb,KAAK,CAAC,KAAK,CAAC;SACZ,MAAM,CAAC,OAAO,CAAC;SACf,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC;SACX,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;SAChB,IAAI,CAAC,EAAE,CAAC;SACR,WAAW,EAAE,CAAC;IACjB,OAAO,GAAG,IAAI,GAAG,CAAC;AACpB,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,GAAqB;IAC/C,OAAO;QACL,EAAE,EAAE,GAAG,CAAC,GAAG;QACX,QAAQ,EAAE,GAAG,CAAC,IAAI;QAClB,KAAK,EAAE,GAAG,CAAC,UAAU;QACrB,OAAO,EAAE,IAAI;QACb,MAAM,EAAE,IAAI;QACZ,KAAK,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,UAAU;QAC7C,GAAG,EAAE,IAAI;QACT,MAAM,EAAE,IAAI;QACZ,GAAG,EAAE,IAAI;QACT,MAAM,EAAE,GAAG,CAAC,MAAM;QAClB,GAAG,EAAE,GAAG,CAAC,cAAc;QACvB,SAAS,EAAE,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC;QAChC,QAAQ,EAAE,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC;QAC/B,OAAO,EAAE,GAAG,CAAC,OAAO;QACpB,cAAc,EAAE,GAAG,CAAC,cAAc;QAClC,UAAU,EAAE,GAAG,CAAC,UAAU;KAC3B,CAAC;AACJ,CAAC"}
\ No newline at end of file
diff --git a/server/dist/sources/types.js b/server/dist/sources/types.js
new file mode 100644
index 00000000..31929c07
--- /dev/null
+++ b/server/dist/sources/types.js
@@ -0,0 +1,6 @@
+// The source-adapter contract, ported from d-combine (sources//adapter.mjs). One object per
+// source captures ONLY what differs between sources; the generic core (buildSource, proxyHandler,
+// playlist) consumes any adapter without per-source branching. Adding a source = one adapter +
+// one registry line.
+export {};
+//# sourceMappingURL=types.js.map
\ No newline at end of file
diff --git a/server/dist/sources/types.js.map b/server/dist/sources/types.js.map
new file mode 100644
index 00000000..8aa17192
--- /dev/null
+++ b/server/dist/sources/types.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/sources/types.ts"],"names":[],"mappings":"AAAA,gGAAgG;AAChG,kGAAkG;AAClG,+FAA+F;AAC/F,qBAAqB"}
\ No newline at end of file
diff --git a/server/package-lock.json b/server/package-lock.json
new file mode 100644
index 00000000..3de6a1f2
--- /dev/null
+++ b/server/package-lock.json
@@ -0,0 +1,1705 @@
+{
+ "name": "tvapp2-server",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "tvapp2-server",
+ "version": "0.1.0",
+ "dependencies": {
+ "express": "^4.21.2",
+ "mongoose": "^8.9.5"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.0",
+ "@types/node": "^22.10.5",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.2"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
+ "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
+ "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
+ "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
+ "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
+ "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
+ "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
+ "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
+ "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
+ "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
+ "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
+ "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
+ "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
+ "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
+ "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
+ "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
+ "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
+ "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
+ "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
+ "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
+ "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
+ "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@mongodb-js/saslprep": {
+ "version": "1.4.11",
+ "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.11.tgz",
+ "integrity": "sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==",
+ "license": "MIT",
+ "dependencies": {
+ "sparse-bitfield": "^3.0.3"
+ }
+ },
+ "node_modules/@types/body-parser": {
+ "version": "1.19.6",
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/connect": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/express": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^5.0.0",
+ "@types/serve-static": "^2"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
+ "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/http-errors": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
+ "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/webidl-conversions": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
+ "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/whatwg-url": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
+ "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/webidl-conversions": "*"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.5",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/bson": {
+ "version": "6.10.4",
+ "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz",
+ "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=16.20.1"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
+ "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.0",
+ "@esbuild/android-arm": "0.28.0",
+ "@esbuild/android-arm64": "0.28.0",
+ "@esbuild/android-x64": "0.28.0",
+ "@esbuild/darwin-arm64": "0.28.0",
+ "@esbuild/darwin-x64": "0.28.0",
+ "@esbuild/freebsd-arm64": "0.28.0",
+ "@esbuild/freebsd-x64": "0.28.0",
+ "@esbuild/linux-arm": "0.28.0",
+ "@esbuild/linux-arm64": "0.28.0",
+ "@esbuild/linux-ia32": "0.28.0",
+ "@esbuild/linux-loong64": "0.28.0",
+ "@esbuild/linux-mips64el": "0.28.0",
+ "@esbuild/linux-ppc64": "0.28.0",
+ "@esbuild/linux-riscv64": "0.28.0",
+ "@esbuild/linux-s390x": "0.28.0",
+ "@esbuild/linux-x64": "0.28.0",
+ "@esbuild/netbsd-arm64": "0.28.0",
+ "@esbuild/netbsd-x64": "0.28.0",
+ "@esbuild/openbsd-arm64": "0.28.0",
+ "@esbuild/openbsd-x64": "0.28.0",
+ "@esbuild/openharmony-arm64": "0.28.0",
+ "@esbuild/sunos-x64": "0.28.0",
+ "@esbuild/win32-arm64": "0.28.0",
+ "@esbuild/win32-ia32": "0.28.0",
+ "@esbuild/win32-x64": "0.28.0"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.15.1",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/kareem": {
+ "version": "2.6.3",
+ "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz",
+ "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/memory-pager": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
+ "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
+ "license": "MIT"
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mongodb": {
+ "version": "6.20.0",
+ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz",
+ "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@mongodb-js/saslprep": "^1.3.0",
+ "bson": "^6.10.4",
+ "mongodb-connection-string-url": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=16.20.1"
+ },
+ "peerDependencies": {
+ "@aws-sdk/credential-providers": "^3.188.0",
+ "@mongodb-js/zstd": "^1.1.0 || ^2.0.0",
+ "gcp-metadata": "^5.2.0",
+ "kerberos": "^2.0.1",
+ "mongodb-client-encryption": ">=6.0.0 <7",
+ "snappy": "^7.3.2",
+ "socks": "^2.7.1"
+ },
+ "peerDependenciesMeta": {
+ "@aws-sdk/credential-providers": {
+ "optional": true
+ },
+ "@mongodb-js/zstd": {
+ "optional": true
+ },
+ "gcp-metadata": {
+ "optional": true
+ },
+ "kerberos": {
+ "optional": true
+ },
+ "mongodb-client-encryption": {
+ "optional": true
+ },
+ "snappy": {
+ "optional": true
+ },
+ "socks": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/mongodb-connection-string-url": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
+ "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/whatwg-url": "^11.0.2",
+ "whatwg-url": "^14.1.0 || ^13.0.0"
+ }
+ },
+ "node_modules/mongoose": {
+ "version": "8.24.0",
+ "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.24.0.tgz",
+ "integrity": "sha512-EEZwOibDPZ5uZN3bFapfnRskEbdljAf6sP9ln6u+P4e5IfkOAh6Tqw2g8/Tag++KHOAJ095WXT/c0uqRq4Vckg==",
+ "license": "MIT",
+ "dependencies": {
+ "bson": "^6.10.4",
+ "kareem": "2.6.3",
+ "mongodb": "~6.20.0",
+ "mpath": "0.9.0",
+ "mquery": "5.0.0",
+ "ms": "2.1.3",
+ "sift": "17.1.3"
+ },
+ "engines": {
+ "node": ">=16.20.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mongoose"
+ }
+ },
+ "node_modules/mongoose/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/mpath": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
+ "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/mquery": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz",
+ "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4.x"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/mquery/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/mquery/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+ "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/sift": {
+ "version": "17.1.3",
+ "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz",
+ "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
+ "license": "MIT"
+ },
+ "node_modules/sparse-bitfield": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
+ "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
+ "license": "MIT",
+ "dependencies": {
+ "memory-pager": "^1.0.2"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx": {
+ "version": "4.22.3",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
+ "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 00000000..c5119af4
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "tvapp2-server",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "dist/index.js",
+ "scripts": {
+ "build": "tsc -p .",
+ "start": "node dist/index.js",
+ "dev": "tsx watch src/index.ts",
+ "seed": "tsx src/seed.ts"
+ },
+ "dependencies": {
+ "express": "^4.21.2",
+ "mongoose": "^8.9.5"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.0",
+ "@types/node": "^22.10.5",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/server/seed-data/sources/dulo.snapshot.json b/server/seed-data/sources/dulo.snapshot.json
new file mode 100644
index 00000000..0776d264
--- /dev/null
+++ b/server/seed-data/sources/dulo.snapshot.json
@@ -0,0 +1,1324 @@
+{
+ "channels": [
+ {
+ "id": "0b56be49-557e-4315-9258-e9a0728ab608",
+ "name": "A&E East HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/2afff205-c2ef-4621-a716-62e65d313b5c.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/AandENetwork.us.png",
+ "created_at": "2026-03-21T02:29:23.917732+00:00",
+ "updated_at": "2026-05-29T05:38:04.349314+00:00"
+ },
+ {
+ "id": "5e5ba7f3-6967-4504-9f26-b9f17ec42ead",
+ "name": "ABC HD | USA",
+ "category": "news",
+ "source_url": "https://images.dulo.tv/memfs/cf396710-6c4d-4c85-9021-df9c24fd6c0b.m3u8",
+ "epg_source_url": "https://epg.pw/last/467679.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/ABC%20%28copy%29.png",
+ "created_at": "2026-03-21T02:30:00.439868+00:00",
+ "updated_at": "2026-05-29T05:39:06.728862+00:00"
+ },
+ {
+ "id": "e2ce7dba-a5e5-4b5c-a935-341f4a08ddeb",
+ "name": "ACC Network HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/a2a7afae-9bc8-410f-8383-eb1e4117d2f9.m3u8",
+ "epg_source_url": "https://epg.pw/last/464879.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/ACCNetwork.us.png",
+ "created_at": "2026-03-21T02:30:40.651545+00:00",
+ "updated_at": "2026-05-31T17:47:37.753669+00:00"
+ },
+ {
+ "id": "afa5347e-e2ab-403c-8670-61f8c4fd47f1",
+ "name": "Al Jazeera HD | USA",
+ "category": "news",
+ "source_url": "https://images.dulo.tv/memfs/bd712599-b656-49a7-9613-a60e8615bdf4.m3u8",
+ "epg_source_url": "https://epg.pw/last/470415.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/AlJazeera.us.png",
+ "created_at": "2026-03-21T02:31:24.750662+00:00",
+ "updated_at": "2026-05-29T05:41:06.853417+00:00"
+ },
+ {
+ "id": "e06e04f5-6e51-42f8-a3c0-7f7bcadb50be",
+ "name": "AMC HD | USA",
+ "category": "movies",
+ "source_url": "https://images.dulo.tv/memfs/a78581f9-fb9a-428c-bfaf-8a6fc0978032.m3u8",
+ "epg_source_url": "https://epg.pw/last/465032.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/AMC.us.png",
+ "created_at": "2026-03-21T02:32:01.414801+00:00",
+ "updated_at": "2026-05-29T06:24:19.490684+00:00"
+ },
+ {
+ "id": "cd124020-4cc8-460f-b730-e0393015d164",
+ "name": "Animal Planet HD | USA",
+ "category": "documentary",
+ "source_url": "https://hey.dulo.tv/memfs/990a73ff-81d2-4222-92d0-6d1b4330245c.m3u8",
+ "epg_source_url": "https://epg.pw/last/465310.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/AnimalPlanet.us.png",
+ "created_at": "2026-03-21T02:32:41.748814+00:00",
+ "updated_at": "2026-05-31T04:05:36.889853+00:00"
+ },
+ {
+ "id": "bd829da5-d754-4e02-bdf5-65a14e666c43",
+ "name": "AT&T SportsNet Pittsburgh HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/f87bbb2d-ae83-4797-9400-e0efc48e975c.m3u8",
+ "epg_source_url": "https://epg.pw/last/465124.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/ATTSportsNetPittsburgh.us.png",
+ "created_at": "2026-03-21T02:33:30.494861+00:00",
+ "updated_at": "2026-05-08T20:00:49.000074+00:00"
+ },
+ {
+ "id": "35427165-289d-43b6-82bc-833c2e6e1341",
+ "name": "beIN Sports HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/e0d84ef1-56aa-4e76-b04e-86bf01a69759.m3u8",
+ "epg_source_url": "https://epg.pw/last/464754.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/beINSports.us.png",
+ "created_at": "2026-03-21T02:34:09.504415+00:00",
+ "updated_at": "2026-05-08T20:02:21.392525+00:00"
+ },
+ {
+ "id": "a38ac31c-23fd-42f1-aa43-a5a30a2d7554",
+ "name": "BET HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/b8299a0d-07ec-40cb-b997-201434cbfefa.m3u8",
+ "epg_source_url": "https://epg.pw/last/464968.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/BET.us.png",
+ "created_at": "2026-03-21T02:35:28.778134+00:00",
+ "updated_at": "2026-05-29T06:31:04.335649+00:00"
+ },
+ {
+ "id": "15be82f9-7d1c-4035-8132-afcefe933063",
+ "name": "Big Ten Network HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/b612ee1f-468a-452c-b0fd-48c3ba99ff8a.m3u8",
+ "epg_source_url": "https://epg.pw/last/465073.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/BigTen.us.png",
+ "created_at": "2026-03-21T02:36:15.8308+00:00",
+ "updated_at": "2026-05-29T06:33:06.554696+00:00"
+ },
+ {
+ "id": "e18e2a96-41e4-4638-8402-f46ada39272d",
+ "name": "Boomerang HD | USA",
+ "category": "kids",
+ "source_url": "https://hey.dulo.tv/memfs/b48d96c2-dd3c-4a6c-a5ae-41bf2220a61e.m3u8",
+ "epg_source_url": "https://epg.pw/last/464755.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/Boomerang.us.png",
+ "created_at": "2026-03-21T02:36:52.17059+00:00",
+ "updated_at": "2026-05-31T04:03:25.431848+00:00"
+ },
+ {
+ "id": "9149b685-782f-4560-941a-7624d0857552",
+ "name": "Bravo East HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/ffe47a87-9e1f-4539-9879-e2fae54d3930.m3u8",
+ "epg_source_url": "https://epg.pw/last/464753.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/Bravo.us.png",
+ "created_at": "2026-03-21T02:37:32.707957+00:00",
+ "updated_at": "2026-05-29T06:35:58.875544+00:00"
+ },
+ {
+ "id": "e4e962a7-7d7c-4361-a271-fc6c52b19466",
+ "name": "C-SPAN HD | USA",
+ "category": "news",
+ "source_url": "https://images.dulo.tv/memfs/c2be2b4a-5f5f-43d9-acde-0110dec73251.m3u8",
+ "epg_source_url": "https://epg.pw/last/464835.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/CSPAN.us.png",
+ "created_at": "2026-03-21T02:38:22.183331+00:00",
+ "updated_at": "2026-05-29T06:36:45.834137+00:00"
+ },
+ {
+ "id": "2e438a75-284a-4fe9-b64b-9fe3faf0765a",
+ "name": "Cartoon Network East HD | USA",
+ "category": "kids",
+ "source_url": "https://hey.dulo.tv/memfs/0ca12fc0-73c4-4c1c-a259-82cd7d90f580.m3u8",
+ "epg_source_url": "https://epg.pw/last/465267.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/CartoonNetwork.us.png",
+ "created_at": "2026-03-21T02:39:21.717696+00:00",
+ "updated_at": "2026-05-31T02:31:08.839163+00:00"
+ },
+ {
+ "id": "5b2ceda1-36f9-4f47-aea9-fa8d04b3aa46",
+ "name": "CMT HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/3f2389ea-aba0-41e0-95a2-57e020b34858.m3u8",
+ "epg_source_url": "https://epg.pw/last/465107.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/CMT.us.png",
+ "created_at": "2026-03-21T02:39:48.207806+00:00",
+ "updated_at": "2026-05-29T06:39:47.470837+00:00"
+ },
+ {
+ "id": "a55f3e69-7941-4f12-9300-83a61f8f7339",
+ "name": "CNBC HD | USA",
+ "category": "news",
+ "source_url": "https://images.dulo.tv/memfs/f9d027af-a9d2-4c59-865e-7101f7602966.m3u8",
+ "epg_source_url": "https://epg.pw/last/464791.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/CNBC.us.png",
+ "created_at": "2026-03-21T02:40:33.795357+00:00",
+ "updated_at": "2026-05-29T06:40:36.797817+00:00"
+ },
+ {
+ "id": "d435a43b-8fee-4a39-86c6-dc24f494bdcc",
+ "name": "CNN HD | USA",
+ "category": "news",
+ "source_url": "https://images.dulo.tv/memfs/bd76a4bc-4dec-4fc7-b200-e0b9f0302dbf.m3u8",
+ "epg_source_url": "https://epg.pw/last/464857.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/CNN.us.png",
+ "created_at": "2026-03-21T02:41:05.259824+00:00",
+ "updated_at": "2026-05-29T06:41:29.9035+00:00"
+ },
+ {
+ "id": "3b00d176-d30d-4818-aeca-04f8bb3a8006",
+ "name": "Comedy Central HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/e3d94c5a-a969-491a-9527-092b9ebb3424.m3u8",
+ "epg_source_url": "https://epg.pw/last/464922.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/ComedyCentral.us.png",
+ "created_at": "2026-03-21T02:42:46.336185+00:00",
+ "updated_at": "2026-05-29T06:43:11.626359+00:00"
+ },
+ {
+ "id": "4e95c735-dda1-4717-87c1-7ffff63fddff",
+ "name": "Discovery Channel HD | USA",
+ "category": "documentary",
+ "source_url": "https://hey.dulo.tv/memfs/cc4fe5ad-82d3-46cf-b3ec-6214772ce8c9.m3u8",
+ "epg_source_url": "https://epg.pw/last/465364.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/DiscoveryChannel.us.png",
+ "created_at": "2026-03-21T02:43:28.368526+00:00",
+ "updated_at": "2026-05-31T04:06:49.379247+00:00"
+ },
+ {
+ "id": "463e7954-6e71-4d25-b266-50fccc17a5bd",
+ "name": "Disney Channel HD | USA",
+ "category": "kids",
+ "source_url": "https://hey.dulo.tv/memfs/5ae3e3d6-e8de-4e6b-a1e4-44d08c52c1b9.m3u8",
+ "epg_source_url": "https://epg.pw/last/465349.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/DisneyChannel.us.png",
+ "created_at": "2026-03-21T02:44:11.811953+00:00",
+ "updated_at": "2026-05-31T03:57:02.924322+00:00"
+ },
+ {
+ "id": "3664b2cb-31dc-4274-944b-73136a47f26e",
+ "name": "Disney Jr HD | USA",
+ "category": "kids",
+ "source_url": "https://hey.dulo.tv/memfs/edce9dc6-ea78-484d-a6ae-8fc695680c5d.m3u8",
+ "epg_source_url": "https://epg.pw/last/465320.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/DisneyJunior.us.png",
+ "created_at": "2026-03-21T02:44:44.857791+00:00",
+ "updated_at": "2026-05-31T04:41:37.631607+00:00"
+ },
+ {
+ "id": "b6fbc14a-bcdf-4c4d-88e1-78a94aec05f2",
+ "name": "E! HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/d72e883f-c569-4c7f-9e6e-7466fcb77df6.m3u8",
+ "epg_source_url": "https://epg.pw/last/464832.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/EEntertainmentTelevision.us.png",
+ "created_at": "2026-03-21T02:45:58.725723+00:00",
+ "updated_at": "2026-05-29T06:47:21.880639+00:00"
+ },
+ {
+ "id": "e84387d8-0d08-4766-93e0-8d3c39fb721c",
+ "name": "epiX HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/1e79ae69-e06d-4ace-ae7b-c2d8769c2d46.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/Epix.us.png",
+ "created_at": "2026-03-21T02:46:34.951334+00:00",
+ "updated_at": "2026-05-31T03:33:14.713361+00:00"
+ },
+ {
+ "id": "18037a7e-3daa-4ca5-8eb7-40ad0ba09776",
+ "name": "ESPN 2 HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/762c0cd1-e3d5-4cc6-8b5c-fe058f74634c.m3u8",
+ "epg_source_url": "https://epg.pw/last/465373.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/ESPN2.us.png",
+ "created_at": "2026-03-21T02:47:11.201701+00:00",
+ "updated_at": "2026-05-29T06:49:16.830163+00:00"
+ },
+ {
+ "id": "ef9447e6-bee7-4de3-80bc-9eb215a925df",
+ "name": "ESPN HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/22d6a3ff-655d-4a28-be98-b9705d80919b.m3u8",
+ "epg_source_url": "https://epg.pw/last/465198.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/ESPN.us.png",
+ "created_at": "2026-03-21T02:48:55.100839+00:00",
+ "updated_at": "2026-05-29T06:51:26.627098+00:00"
+ },
+ {
+ "id": "d80a1087-c4d1-4c0c-9f7d-1c639784ed7e",
+ "name": "ESPN News HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/a6f4cb11-c02f-4b0b-8fab-ba5147800479.m3u8",
+ "epg_source_url": "https://epg.pw/last/465410.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/ESPNEWS.us.png",
+ "created_at": "2026-03-21T02:49:37.250592+00:00",
+ "updated_at": "2026-05-29T06:50:14.086123+00:00"
+ },
+ {
+ "id": "1263c734-c158-4936-969b-f46e5d21bef4",
+ "name": "ESPN U HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/69bb2fd6-b379-4612-971c-c613e0868ef2.m3u8",
+ "epg_source_url": "https://epg.pw/last/465108.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/ESPNU.us.png",
+ "created_at": "2026-03-21T02:50:12.026918+00:00",
+ "updated_at": "2026-05-29T06:52:30.959232+00:00"
+ },
+ {
+ "id": "e51f0e58-0f0b-476e-b2a5-84033adcc2c2",
+ "name": "Food Network HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/d5952b5e-8fd3-42f7-a6c9-468ea8cbec1d.m3u8",
+ "epg_source_url": "https://epg.pw/last/464984.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/FoodNetwork.us.png",
+ "created_at": "2026-03-21T02:51:00.312647+00:00",
+ "updated_at": "2026-05-29T06:53:48.792833+00:00"
+ },
+ {
+ "id": "9e26f026-1202-4501-bb0a-faa9edd6e6a8",
+ "name": "Fox 5 NY HD | USA",
+ "category": "news",
+ "source_url": "https://images.dulo.tv/memfs/f022aa37-6621-4c5a-b321-4a204c75743a.m3u8",
+ "epg_source_url": "https://epg.pw/last/468913.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/FoxEast_WNYW.us.png",
+ "created_at": "2026-03-21T02:51:42.636025+00:00",
+ "updated_at": "2026-05-29T06:55:12.363287+00:00"
+ },
+ {
+ "id": "b97baa93-8be4-4dd7-b7bf-29f24fe401f2",
+ "name": "Fox Business HD | USA",
+ "category": "news",
+ "source_url": "https://images.dulo.tv/memfs/9509f30b-0b7a-4761-b18d-cb47644a414b.m3u8",
+ "epg_source_url": "https://epg.pw/last/464766.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/FoxBusiness.us.png",
+ "created_at": "2026-03-21T02:53:01.618654+00:00",
+ "updated_at": "2026-05-29T06:56:29.271963+00:00"
+ },
+ {
+ "id": "509cda12-8077-4c82-a2aa-10ba512fcc75",
+ "name": "Fox News HD | USA",
+ "category": "news",
+ "source_url": "https://images.dulo.tv/memfs/f2134c41-965e-4ffa-9d0b-3a8fb0b576e7.m3u8",
+ "epg_source_url": "https://epg.pw/last/465372.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/FoxNewsChannel.us.png",
+ "created_at": "2026-03-21T02:53:51.758717+00:00",
+ "updated_at": "2026-05-29T05:35:55.203506+00:00"
+ },
+ {
+ "id": "4567e88a-0e47-4e61-ab50-6d9ff92252e2",
+ "name": "Fox Sports 1 HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/124a2e29-8677-48cc-b8be-2b00f748e497.m3u8",
+ "epg_source_url": "https://epg.pw/last/465291.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/FoxSports1.us.png",
+ "created_at": "2026-03-21T02:55:22.886779+00:00",
+ "updated_at": "2026-05-29T07:00:49.10867+00:00"
+ },
+ {
+ "id": "22483c87-bd9c-4880-8970-98a532680d40",
+ "name": "Fox Sports 2 HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/d1f8a09a-0c14-4445-a3a4-9b27982da2fb.m3u8",
+ "epg_source_url": "https://epg.pw/last/465355.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/FoxSports2.us.png",
+ "created_at": "2026-03-21T02:55:59.774207+00:00",
+ "updated_at": "2026-05-29T07:03:35.704252+00:00"
+ },
+ {
+ "id": "3118f2f8-b956-4214-9c58-b07e78a06b21",
+ "name": "FreeForm HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/e7af2de6-2703-47dd-9af1-10b3048b1e9d.m3u8",
+ "epg_source_url": "https://epg.pw/last/465331.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/Freeform.us.png",
+ "created_at": "2026-03-21T02:56:52.600199+00:00",
+ "updated_at": "2026-05-29T07:05:56.144856+00:00"
+ },
+ {
+ "id": "08369fe5-f318-4a89-982d-9f63c537302e",
+ "name": "FX HD | USA",
+ "category": "movies",
+ "source_url": "https://hey.dulo.tv/memfs/8a3432fb-04a7-4ade-a28f-5c1a0cc3f68e.m3u8",
+ "epg_source_url": "https://epg.pw/last/465400.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/FX.us.png",
+ "created_at": "2026-03-21T02:58:31.867323+00:00",
+ "updated_at": "2026-05-31T04:12:55.759989+00:00"
+ },
+ {
+ "id": "da593975-ed36-41eb-889d-defc075ac64a",
+ "name": "Hallmark Family HD | USA",
+ "category": "movies",
+ "source_url": "https://hey.dulo.tv/memfs/2fab5943-6302-4d69-beaf-57c64e65a3d5.m3u8",
+ "epg_source_url": "https://epg.pw/last/464897.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/HallmarkFamily.us.png",
+ "created_at": "2026-03-21T02:59:20.6632+00:00",
+ "updated_at": "2026-05-31T04:14:47.223369+00:00"
+ },
+ {
+ "id": "19395309-c667-409d-9104-3196557a6c2a",
+ "name": "HBO 2 HD | USA",
+ "category": "movies",
+ "source_url": "https://hey.dulo.tv/memfs/af8aa9b5-ddaf-483f-bbbc-34bc24d180c1.m3u8",
+ "epg_source_url": "https://epg.pw/last/465041.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/HBO2.us.png",
+ "created_at": "2026-03-21T03:02:20.451424+00:00",
+ "updated_at": "2026-05-31T04:16:00.039049+00:00"
+ },
+ {
+ "id": "f9a9b988-868e-4014-8bcd-fe32da862d39",
+ "name": "HBO Family HD | USA",
+ "category": "movies",
+ "source_url": "https://hey.dulo.tv/memfs/4024db2b-e32f-49e2-936c-db299b7b3c70.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/HBOFamily.us.png",
+ "created_at": "2026-03-21T03:06:01.005499+00:00",
+ "updated_at": "2026-05-31T04:17:20.507524+00:00"
+ },
+ {
+ "id": "4499e99d-8e5b-4087-9d51-9c61a820ca02",
+ "name": "HBO HD | USA",
+ "category": "movies",
+ "source_url": "https://hey.dulo.tv/memfs/40b05b79-ee37-4d80-81be-fecb99539003.m3u8",
+ "epg_source_url": "https://epg.pw/last/464745.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/HBO.us.png",
+ "created_at": "2026-03-21T03:06:42.119801+00:00",
+ "updated_at": "2026-06-01T01:21:54.617392+00:00"
+ },
+ {
+ "id": "fabb4415-d644-44dd-b083-dab8c77d8833",
+ "name": "HBO Latino HD | USA",
+ "category": "movies",
+ "source_url": "v",
+ "epg_source_url": "https://epg.pw/last/464960.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/HBOLatino.us.png",
+ "created_at": "2026-03-21T03:07:23.376546+00:00",
+ "updated_at": "2026-05-29T07:15:46.889441+00:00"
+ },
+ {
+ "id": "8cf4517b-a7c4-4e97-b2de-7ff3ada11543",
+ "name": "HBO Signature HD | USA",
+ "category": "movies",
+ "source_url": "https://hey.dulo.tv/memfs/51d76cab-aff4-435a-ae57-88c108092ba7.m3u8",
+ "epg_source_url": "https://epg.pw/last/465279.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/HBOSignature.us.png",
+ "created_at": "2026-03-21T03:08:02.732725+00:00",
+ "updated_at": "2026-05-31T04:21:39.827678+00:00"
+ },
+ {
+ "id": "72b7a1df-54ef-4ca1-9c82-e526977775c5",
+ "name": "HGTV HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/6ed802f7-51b2-459d-8f90-4198bc46f61f.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/HGTV.us.png",
+ "created_at": "2026-03-21T03:09:02.609226+00:00",
+ "updated_at": "2026-05-29T07:17:45.87031+00:00"
+ },
+ {
+ "id": "6f629954-45bb-4263-a58e-ba47f9277ef5",
+ "name": "History HD | USA",
+ "category": "documentary",
+ "source_url": "https://hey.dulo.tv/memfs/5fb5f1b0-c84e-413d-aa65-fe96d832f1ac.m3u8",
+ "epg_source_url": "https://epg.pw/last/465176.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/History.us.png",
+ "created_at": "2026-03-21T03:09:32.616062+00:00",
+ "updated_at": "2026-05-31T04:07:59.946592+00:00"
+ },
+ {
+ "id": "50fea76c-3c5b-45b9-a8c2-31093fd6541b",
+ "name": "Investigation Discovery HD | USA",
+ "category": "documentary",
+ "source_url": "https://hey.dulo.tv/memfs/cc30205b-d5b7-47ca-bf66-784f4dbe9bdd.m3u8",
+ "epg_source_url": "https://epg.pw/last/465294.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/InvestigationDiscovery.us.png",
+ "created_at": "2026-03-21T03:10:17.256111+00:00",
+ "updated_at": "2026-05-31T04:09:42.485452+00:00"
+ },
+ {
+ "id": "dc23c106-df27-4364-9dcc-7d3a30ad44e2",
+ "name": "Lifetime HD | TV",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/c00b0f90-4a1b-4846-b676-c2eeff8a01bf.m3u8",
+ "epg_source_url": "https://epg.pw/last/465290.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/Lifetime.us.png",
+ "created_at": "2026-03-21T03:11:13.848025+00:00",
+ "updated_at": "2026-05-29T07:22:30.630607+00:00"
+ },
+ {
+ "id": "d0bc8a90-1bd2-4e7f-8875-0b2be40888f8",
+ "name": "Magnolia Network HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/0a16d94a-fda9-4d63-bb7a-c7003f7336c8.m3u8",
+ "epg_source_url": "https://epg.pw/last/465012.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/MagnoliaNetwork.us.png",
+ "created_at": "2026-03-21T03:11:51.707394+00:00",
+ "updated_at": "2026-05-29T07:21:16.53881+00:00"
+ },
+ {
+ "id": "88242062-2bfd-4547-9043-b2189399f43e",
+ "name": "MLB Network HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/a185d75f-7635-4b51-bb42-e6d776917d14.m3u8",
+ "epg_source_url": "https://epg.pw/last/465239.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/MLBNetwork.us.png",
+ "created_at": "2026-03-21T03:18:32.884349+00:00",
+ "updated_at": "2026-05-29T07:23:35.949174+00:00"
+ },
+ {
+ "id": "ae1bbb1c-fce3-4768-b13a-1417b27e0c76",
+ "name": "MSG HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/84239837-d11a-43d7-a645-923e1554e7ba.m3u8",
+ "epg_source_url": "https://epg.pw/last/464996.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/MSG.us.png",
+ "created_at": "2026-03-21T03:19:11.991924+00:00",
+ "updated_at": "2026-05-29T07:24:50.957453+00:00"
+ },
+ {
+ "id": "a3d24518-0ec0-4a60-bb74-43e62f923277",
+ "name": "MSNBC HD | USA",
+ "category": "news",
+ "source_url": "https://images.dulo.tv/memfs/e77e70b2-f513-4f5c-ba45-42743dbc5453.m3u8",
+ "epg_source_url": "https://epg.pw/last/465087.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/MSNBC.us.png",
+ "created_at": "2026-03-21T03:19:51.602647+00:00",
+ "updated_at": "2026-05-29T07:25:48.980452+00:00"
+ },
+ {
+ "id": "96d5474d-facb-48f0-875e-6e1836ecf5c2",
+ "name": "MTV HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/4484de6d-491b-4a3d-967f-08e68b63b174.m3u8",
+ "epg_source_url": "https://epg.pw/last/464997.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/MTV.us.png",
+ "created_at": "2026-03-21T03:20:42.8248+00:00",
+ "updated_at": "2026-05-29T07:26:58.397976+00:00"
+ },
+ {
+ "id": "2798d6d7-b2e5-46f0-8753-7be9c3cfcf22",
+ "name": "National Geographic HD | USA",
+ "category": "documentary",
+ "source_url": "https://hey.dulo.tv/memfs/a9835c70-a51d-4e4a-a001-6ffc18dcc9ba.m3u8",
+ "epg_source_url": "https://epg.pw/last/465089.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/NationalGeographic.us.png",
+ "created_at": "2026-03-21T03:22:45.607231+00:00",
+ "updated_at": "2026-05-31T04:10:59.725608+00:00"
+ },
+ {
+ "id": "2d707c68-d53a-4174-9b3a-78e44b300e63",
+ "name": "NBA TV HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/0d6106f5-50e4-43f1-8ab5-431dac6fc4c0.m3u8",
+ "epg_source_url": "https://epg.pw/last/464912.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/NBAtv.us.png",
+ "created_at": "2026-03-21T03:23:17.032514+00:00",
+ "updated_at": "2026-05-29T07:28:53.544655+00:00"
+ },
+ {
+ "id": "671a4cce-efa5-4254-a2fc-d17278e3ded3",
+ "name": "NESN HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/8bd363bf-6fb2-4315-ac1d-1c1952f7f4cd.m3u8",
+ "epg_source_url": "https://epg.pw/last/496625.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/NESN.us.png",
+ "created_at": "2026-03-21T03:24:12.362053+00:00",
+ "updated_at": "2026-05-29T07:29:45.835839+00:00"
+ },
+ {
+ "id": "312c8261-c482-4346-8437-83eb9436c91b",
+ "name": "NFL Network HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/15816015-c1d0-47da-81e9-ae68e9d2b67f.m3u8",
+ "epg_source_url": "https://epg.pw/last/465311.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/NFLNetwork.us.png",
+ "created_at": "2026-03-21T03:25:18.812068+00:00",
+ "updated_at": "2026-05-29T07:30:58.034779+00:00"
+ },
+ {
+ "id": "1e89de9c-999b-4193-8414-a444631e3728",
+ "name": "NHL Network HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/4546b90f-28a7-4f12-88c3-856df043e237.m3u8",
+ "epg_source_url": "https://epg.pw/last/465348.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/NHLNetwork.us.png",
+ "created_at": "2026-03-21T03:25:52.205833+00:00",
+ "updated_at": "2026-05-29T07:31:50.372489+00:00"
+ },
+ {
+ "id": "6b75e415-e3a5-4815-9cf2-237e7f11ae2e",
+ "name": "Nickelodeon East HD | USA",
+ "category": "kids",
+ "source_url": "https://hey.dulo.tv/memfs/59c180f4-1bdc-488c-8e29-9e051f477392.m3u8",
+ "epg_source_url": "https://epg.pw/last/465251.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/Nickelodeon.us.png",
+ "created_at": "2026-03-21T03:26:51.363775+00:00",
+ "updated_at": "2026-05-31T04:00:33.085061+00:00"
+ },
+ {
+ "id": "861952e3-3157-4635-9944-d2d074a817ab",
+ "name": "Nicktoons HD | USA",
+ "category": "kids",
+ "source_url": "https://hey.dulo.tv/memfs/4108a8ad-602e-4a25-9f71-21cd1338a403.m3u8",
+ "epg_source_url": "https://epg.pw/last/465038.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/Nicktoons.us.png",
+ "created_at": "2026-03-21T03:27:22.543312+00:00",
+ "updated_at": "2026-05-31T04:02:04.2053+00:00"
+ },
+ {
+ "id": "27dfd93f-08da-4a65-8d9f-b174f7062d79",
+ "name": "OWN HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/ca273200-b240-48cc-b03a-31da3e2e7d75.m3u8",
+ "epg_source_url": "https://epg.pw/last/465259.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/OWN.us.png",
+ "created_at": "2026-03-21T03:28:06.545349+00:00",
+ "updated_at": "2026-05-29T07:35:37.181278+00:00"
+ },
+ {
+ "id": "be1f5f0e-80c8-4fb1-a2ff-fe6a6e55e2de",
+ "name": "Paramount Network HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/e8226677-87ba-4f1f-ba23-7b992c6336fb.m3u8",
+ "epg_source_url": "https://epg.pw/last/464882.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/ParamountNetwork.us.png",
+ "created_at": "2026-03-21T03:28:38.876193+00:00",
+ "updated_at": "2026-05-29T07:36:55.489839+00:00"
+ },
+ {
+ "id": "116bab23-7ece-4230-94d1-796631e244c5",
+ "name": "Showtime Extreme HD | USA",
+ "category": "movies",
+ "source_url": "https://hey.dulo.tv/memfs/ba77ea81-a931-48aa-964e-6536852f60cb.m3u8",
+ "epg_source_url": "https://epg.pw/last/464877.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/ShowtimeExtreme.us.png",
+ "created_at": "2026-03-21T03:30:39.298218+00:00",
+ "updated_at": "2026-05-31T04:24:31.087636+00:00"
+ },
+ {
+ "id": "62f075b0-cdd3-4fdb-b0e9-e32c49285836",
+ "name": "Sony Movie Channel HD | USA",
+ "category": "movies",
+ "source_url": "https://hey.dulo.tv/memfs/75ddfc2c-aee3-4fd2-a70d-d368b1e44736.m3u8",
+ "epg_source_url": "https://epg.pw/last/464943.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/SonyMovieChannel.us.png",
+ "created_at": "2026-03-21T03:37:54.799048+00:00",
+ "updated_at": "2026-05-31T04:28:30.797998+00:00"
+ },
+ {
+ "id": "b027a9ab-e147-405e-b77c-868f2f225300",
+ "name": "Spectrum SportsNet LA Dodgers HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/c12d46a2-a58e-4dd8-a178-533365efc760.m3u8",
+ "epg_source_url": "https://epg.pw/last/465077.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/SpectrumSportsNetLADodgers.us.png",
+ "created_at": "2026-03-21T03:39:06.913655+00:00",
+ "updated_at": "2026-05-29T07:42:48.918011+00:00"
+ },
+ {
+ "id": "5c83b496-df9e-4dae-9554-d93cd482177a",
+ "name": "Starz Cinema HD | USA",
+ "category": "movies",
+ "source_url": "https://hey.dulo.tv/memfs/2df70247-3213-47e3-8553-bc8c28d0c67f.m3u8",
+ "epg_source_url": "https://epg.pw/last/465115.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/StarzCinema.us.png",
+ "created_at": "2026-03-21T03:40:15.481353+00:00",
+ "updated_at": "2026-05-31T04:29:37.115273+00:00"
+ },
+ {
+ "id": "667683d0-e7d7-4f10-af12-5174ee74d8ed",
+ "name": "Starz East HD | USA",
+ "category": "movies",
+ "source_url": "https://hey.dulo.tv/memfs/8789fb20-a653-465f-b491-937724e3765b.m3u8",
+ "epg_source_url": "https://epg.pw/last/464975.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/Starz.us.png",
+ "created_at": "2026-03-21T03:40:41.308802+00:00",
+ "updated_at": "2026-05-31T04:30:59.041617+00:00"
+ },
+ {
+ "id": "126761fd-9c50-46d7-9821-a7503528fe7f",
+ "name": "Starz Encore HD | USA",
+ "category": "movies",
+ "source_url": "https://hey.dulo.tv/memfs/5e336b09-a46c-4a0f-b72f-886c30dc92cb.m3u8",
+ "epg_source_url": "https://epg.pw/last/464893.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/StarzEncore.us.png",
+ "created_at": "2026-03-21T03:41:06.243462+00:00",
+ "updated_at": "2026-05-31T04:32:13.702714+00:00"
+ },
+ {
+ "id": "11d8bbd6-b65b-4138-ac22-0194346db38e",
+ "name": "SyFy HD | USA",
+ "category": "movies",
+ "source_url": "https://hey.dulo.tv/memfs/010d6d1d-a8ed-42dc-abc5-82124ede2a59.m3u8",
+ "epg_source_url": "https://epg.pw/last/465053.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/Syfy.us.png",
+ "created_at": "2026-03-21T03:41:42.96235+00:00",
+ "updated_at": "2026-05-31T04:34:35.485667+00:00"
+ },
+ {
+ "id": "b1428e38-1dd2-41ef-94b6-f2f24b435a63",
+ "name": "Tennis Channel HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/632c60eb-7798-4695-b617-0271e22b47d1.m3u8",
+ "epg_source_url": "https://epg.pw/last/465236.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/TennisChannel.us.png",
+ "created_at": "2026-03-21T03:42:21.39763+00:00",
+ "updated_at": "2026-05-29T07:51:03.463954+00:00"
+ },
+ {
+ "id": "5d135d5f-23f5-4c77-881e-dd074ad1844e",
+ "name": "The Weather Channel HD | USA",
+ "category": "news",
+ "source_url": "https://images.dulo.tv/memfs/faa13ade-5cc2-451a-86b2-b22de56da424.m3u8",
+ "epg_source_url": "https://epg.pw/last/465272.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/TheWeatherChannel.us.png",
+ "created_at": "2026-03-21T03:43:01.773063+00:00",
+ "updated_at": "2026-05-29T07:53:17.45128+00:00"
+ },
+ {
+ "id": "ac80172e-e8ff-4a06-b956-bc25bf3b519f",
+ "name": "TNT HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/875059b4-6cfa-4be2-9e86-159be43583e8.m3u8",
+ "epg_source_url": "https://epg.pw/last/465114.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/TNT.us.png",
+ "created_at": "2026-03-21T03:43:36.092544+00:00",
+ "updated_at": "2026-05-29T07:54:14.862904+00:00"
+ },
+ {
+ "id": "f822a7fa-ed38-4a95-aa8f-69ad98fcbce7",
+ "name": "TruTV HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/2965b183-a648-4866-b598-3e193785d33f.m3u8",
+ "epg_source_url": "https://epg.pw/last/464987.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/truTV.us.png",
+ "created_at": "2026-03-21T03:44:42.470318+00:00",
+ "updated_at": "2026-05-29T07:55:23.124502+00:00"
+ },
+ {
+ "id": "74d709fa-01e7-4499-bc3e-d1a6b3540948",
+ "name": "USA Network HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/744d72b2-0d9c-45c0-9824-6f030ad06ad3.m3u8",
+ "epg_source_url": "https://epg.pw/last/465006.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/USANetwork.us.png",
+ "created_at": "2026-03-21T03:45:16.676066+00:00",
+ "updated_at": "2026-05-29T07:56:09.163848+00:00"
+ },
+ {
+ "id": "344d07b6-4b72-4aae-94ce-c1a508167d3d",
+ "name": "VH1 HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/34ae4c6e-82c1-4ef4-b871-ac82bf960154.m3u8",
+ "epg_source_url": "https://epg.pw/last/465206.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/VH1.us.png",
+ "created_at": "2026-03-21T03:46:00.402281+00:00",
+ "updated_at": "2026-05-29T07:57:09.238706+00:00"
+ },
+ {
+ "id": "c7e4a13a-142f-433c-8fe3-55cbae1e67d5",
+ "name": "Willow Cricket HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/b66967fb-5afe-4439-a846-01b14ff2f65a.m3u8",
+ "epg_source_url": "https://epg.pw/last/465010.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/WillowCricket.us.png",
+ "created_at": "2026-03-21T03:46:35.98409+00:00",
+ "updated_at": "2026-05-29T07:57:55.395224+00:00"
+ },
+ {
+ "id": "08fa49e0-4f5c-4a10-9f42-4c0249be664b",
+ "name": "Sky Sports F1 HD | UK",
+ "category": "sports",
+ "source_url": "https://hey.dulo.tv/memfs/d43aa050-1751-4711-8ed7-1b434031aa7c.m3u8",
+ "epg_source_url": "https://epg.pw/last/412947.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/UK/SkySportsF1.uk.png",
+ "created_at": "2026-03-21T04:21:48.661475+00:00",
+ "updated_at": "2026-05-31T04:55:19.446477+00:00"
+ },
+ {
+ "id": "969ede28-9cc2-4c65-9454-20fc37d0e2b8",
+ "name": "Sky Sports Main Event HD | UK",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/287d1543-ec5e-49c1-b492-01eb358d1ad5.m3u8",
+ "epg_source_url": "https://epg.pw/last/7673.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/UK/SkySportsMainEvent.uk.png",
+ "created_at": "2026-03-21T04:22:22.419164+00:00",
+ "updated_at": "2026-04-10T15:52:08.818197+00:00"
+ },
+ {
+ "id": "36c4d9d1-4d43-45f7-8477-c1bfbe0ad217",
+ "name": "Sportsnet East HD | CA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/9105ba79-7a42-47e2-b344-3d07c6d0aef9.m3u8",
+ "epg_source_url": "https://epg.pw/last/470812.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/Canada/SportsnetEast.ca.png",
+ "created_at": "2026-03-21T04:27:41.947382+00:00",
+ "updated_at": "2026-04-10T15:52:26.542449+00:00"
+ },
+ {
+ "id": "688ddade-4c5f-4b1a-9fe3-b3565475b5dd",
+ "name": "TSN 1 HD | CA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/3929afa8-022d-4bda-af5c-aa39c33b05de.m3u8",
+ "epg_source_url": "https://epg.pw/last/470446.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/Canada/TSN1.ca.png",
+ "created_at": "2026-03-21T04:28:36.295412+00:00",
+ "updated_at": "2026-05-29T08:04:59.296329+00:00"
+ },
+ {
+ "id": "e7756305-b952-456a-8aa8-8253fcb77209",
+ "name": "TSN 2 HD | CA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/79e4cfd2-676b-48de-b035-54f5ef7dfa28.m3u8",
+ "epg_source_url": "https://epg.pw/last/470550.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/Canada/TSN2.ca.png",
+ "created_at": "2026-03-21T04:31:08.698696+00:00",
+ "updated_at": "2026-05-29T08:06:06.13564+00:00"
+ },
+ {
+ "id": "cf99e9c5-9cb8-41a9-81fd-1e27e56ecead",
+ "name": "TSN 3 HD | CA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/8176f064-c2d0-4cba-9899-c533e80d68a8.m3u8",
+ "epg_source_url": "https://epg.pw/last/470888.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/Canada/TSN3.ca.png",
+ "created_at": "2026-03-21T04:31:59.186974+00:00",
+ "updated_at": "2026-05-29T08:07:13.796767+00:00"
+ },
+ {
+ "id": "83fd2f2e-249f-43b4-b09b-dc9de11137f9",
+ "name": "GSN HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/d8f0da98-667f-4ff1-b090-79ec3df30a97.m3u8",
+ "epg_source_url": "https://epg.pw/last/464810.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/GameShowNetworkPacific.us.png",
+ "created_at": "2026-03-24T20:00:02.586783+00:00",
+ "updated_at": "2026-05-29T08:08:31.4082+00:00"
+ },
+ {
+ "id": "c4dda23a-c202-4e2c-8be8-f5ed5e748534",
+ "name": "TNT Sports 1 HD | UK",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/e773710d-d3e5-4e03-8470-3a6070cf9285.m3u8",
+ "epg_source_url": "https://epg.pw/last/400477.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/UK/TNTSports1.uk.png",
+ "created_at": "2026-03-25T00:36:12.494905+00:00",
+ "updated_at": "2026-05-29T08:09:26.192475+00:00"
+ },
+ {
+ "id": "7f37f6cb-d5c2-4003-87e2-780c942d1f8d",
+ "name": "TNT Sports 2 HD | UK",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/4297a668-7c2f-48dd-b6f3-d07d539c3b26.m3u8",
+ "epg_source_url": "https://epg.pw/last/400480.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/UK/TNTSports2.uk.png",
+ "created_at": "2026-03-25T00:43:02.010045+00:00",
+ "updated_at": "2026-05-29T08:10:01.685651+00:00"
+ },
+ {
+ "id": "e1d60af0-164b-41bf-abd4-1710aed0911b",
+ "name": "NBC Sports Bay Area HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/713cb02d-2bb3-4259-9bb1-f1b8174a16a1.m3u8",
+ "epg_source_url": "https://epg.pw/last/465163.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/NBCSportsBayArea.us.png",
+ "created_at": "2026-03-26T17:43:41.261854+00:00",
+ "updated_at": "2026-05-29T08:11:10.230781+00:00"
+ },
+ {
+ "id": "682e3578-7e54-4e67-b856-79052ee22d8e",
+ "name": "NBC Sports California HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/153aceee-ff14-495c-8620-9cfe9e9e505b.m3u8",
+ "epg_source_url": "https://epg.pw/last/465054.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/NBCSportsCalifornia.us.png",
+ "created_at": "2026-03-26T17:50:35.09763+00:00",
+ "updated_at": "2026-05-29T08:12:08.901538+00:00"
+ },
+ {
+ "id": "a4c38821-8716-4e9e-b755-9524fbae8886",
+ "name": "Travel Channel HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/6642f732-1ad7-4273-b30a-d12cd639f99c.m3u8",
+ "epg_source_url": "https://epg.pw/last/465186.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/TravelChannel.us.png",
+ "created_at": "2026-03-26T18:19:46.814735+00:00",
+ "updated_at": "2026-05-29T08:13:22.968269+00:00"
+ },
+ {
+ "id": "84522924-f79c-4cd4-bb22-df9f19502ce7",
+ "name": "Destination America HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/685d35e8-8e90-4ceb-9241-a18a17056b34.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/DestinationAmerica.us.png",
+ "created_at": "2026-03-26T18:22:32.872215+00:00",
+ "updated_at": "2026-05-29T08:14:21.066497+00:00"
+ },
+ {
+ "id": "a06ea1dd-0304-4325-9f15-5f5e6969849f",
+ "name": "fyi HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/4fd9553b-efc3-4b15-8c81-c27d861fa94d.m3u8",
+ "epg_source_url": "https://epg.pw/last/464774.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/FYIChannel.us.png",
+ "created_at": "2026-03-26T18:26:25.132348+00:00",
+ "updated_at": "2026-05-29T08:15:17.406884+00:00"
+ },
+ {
+ "id": "df2cc909-cdda-4061-bebe-b31d210fa62c",
+ "name": "NBC 4 HD | US",
+ "category": "news",
+ "source_url": "https://hey.dulo.tv/memfs/03e8462d-5cf5-4bca-b134-125e228e5e1d.m3u8",
+ "epg_source_url": "https://epg.pw/last/467015.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/NBCWNBC.us.png",
+ "created_at": "2026-03-26T18:31:17.645806+00:00",
+ "updated_at": "2026-05-31T04:44:29.974662+00:00"
+ },
+ {
+ "id": "4f3ddbdb-38ea-4853-8fad-404f0899b1dd",
+ "name": "CBS 2 NY HD | USA",
+ "category": "news",
+ "source_url": "https://images.dulo.tv/memfs/bbf679fc-bf77-4ac4-a988-b36195bd1e31.m3u8",
+ "epg_source_url": "https://epg.pw/last/468311.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/WCBS.png",
+ "created_at": "2026-03-26T18:33:45.721679+00:00",
+ "updated_at": "2026-05-29T08:17:35.374314+00:00"
+ },
+ {
+ "id": "0e5da8bf-5203-4abf-8dd1-ed7e28b33f10",
+ "name": "Smithsonian Channel HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/3c03d782-4a10-43e4-8209-b23ee2759e88.m3u8",
+ "epg_source_url": "https://epg.pw/last/465042.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/smithsonianchannel.png",
+ "created_at": "2026-03-26T18:40:30.072842+00:00",
+ "updated_at": "2026-05-29T08:18:37.144144+00:00"
+ },
+ {
+ "id": "5e675be4-89a2-43d7-8917-3c6e78acc30b",
+ "name": "TSN 4 HD | CA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/7c780698-133a-48ef-bdbf-585c4b7ab6c9.m3u8",
+ "epg_source_url": "https://epg.pw/last/470730.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/Canada/TSN4.ca.png",
+ "created_at": "2026-04-07T21:21:50.538815+00:00",
+ "updated_at": "2026-05-29T08:19:43.457672+00:00"
+ },
+ {
+ "id": "aaf601ce-6337-4f14-ac22-e2118f49072c",
+ "name": "TSN 5 HD | CA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/48a842fe-0f1d-4daf-ad63-c930ac09d1bf.m3u8",
+ "epg_source_url": "https://epg.pw/last/470390.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/Canada/TSN5.ca.png",
+ "created_at": "2026-04-07T21:23:40.840324+00:00",
+ "updated_at": "2026-05-29T08:20:29.565861+00:00"
+ },
+ {
+ "id": "98a9cd29-0a98-4cb1-8334-18067b9d47ac",
+ "name": "CBS Sports HD | USA",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/46ea23e8-f030-41d4-a5f9-19cb3c5adc92.m3u8",
+ "epg_source_url": "https://epg.pw/last/464937.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/CBSSportsNetwork.us.png",
+ "created_at": "2026-04-07T21:50:54.367966+00:00",
+ "updated_at": "2026-05-29T08:21:58.704388+00:00"
+ },
+ {
+ "id": "94140129-0daf-4343-ab07-50fee471c779",
+ "name": "TBS HD | USA",
+ "category": "entertainment",
+ "source_url": "https://images.dulo.tv/memfs/cffea07a-4d5c-4764-8361-126aa6d28c84.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/TBS.us.png",
+ "created_at": "2026-05-02T19:41:54.342709+00:00",
+ "updated_at": "2026-05-29T08:22:54.580611+00:00"
+ },
+ {
+ "id": "25e200e4-c52b-4a7c-9c17-88665920a5d4",
+ "name": "Sky Sports Football HD | UK",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/918682c9-4b60-475f-a2ef-f619532e8fc1.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/UK/SkySportsFootball.uk.png",
+ "created_at": "2026-05-06T09:18:40.50746+00:00",
+ "updated_at": "2026-05-30T15:20:40.980869+00:00"
+ },
+ {
+ "id": "ab1b856c-bec7-4520-9d70-ac3f6a6a9d36",
+ "name": "Sky Sports Mix HD | UK",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/80c0df20-2f36-4ed7-8a34-5530a3c833fd.m3u8",
+ "epg_source_url": "https://epg.pw/last/6553.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/UK/SkySportsMix.uk.png",
+ "created_at": "2026-05-06T09:20:11.722544+00:00",
+ "updated_at": "2026-05-29T08:26:17.955329+00:00"
+ },
+ {
+ "id": "4403220d-a412-45fa-8458-6dfac6894a6c",
+ "name": "Sky Sports Main Event HD | UK",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/53ff8ded-f296-46ce-a676-fccb8b839ba8.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/UK/SkySportsMainEvent.uk.png",
+ "created_at": "2026-05-06T09:21:54.036345+00:00",
+ "updated_at": "2026-05-29T08:27:33.143056+00:00"
+ },
+ {
+ "id": "8476925a-1dc5-4d8b-b69f-afa073a341ad",
+ "name": "Sky Sports Cricket HD | UK",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/00252d98-9fe9-4275-98b0-c548d029caf5.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/UK/SkySportsCricket.uk.png",
+ "created_at": "2026-05-06T09:30:08.06863+00:00",
+ "updated_at": "2026-05-29T08:28:36.722144+00:00"
+ },
+ {
+ "id": "bb70e26a-4569-4fe3-8b10-9db4a8367933",
+ "name": "Sky Sports Golf HD | UK",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/b646bb4b-a79f-460a-af78-57dd5ee3e42a.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/UK/SkySportsGolf.uk.png",
+ "created_at": "2026-05-06T09:32:12.119012+00:00",
+ "updated_at": "2026-05-29T08:29:46.486484+00:00"
+ },
+ {
+ "id": "00313f16-3d50-428b-bae6-5f7d7e30de2d",
+ "name": "Sky Sports Premier League HD | UK",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/7e585fac-7375-4953-b749-1e1610e84f8c.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/UK/SkySportsPremierLeague.uk.png",
+ "created_at": "2026-05-06T09:35:09.326329+00:00",
+ "updated_at": "2026-05-29T08:30:54.868654+00:00"
+ },
+ {
+ "id": "9050e056-6c10-4f86-9a75-a65fe8598e01",
+ "name": "Star Sports 1 FHD | IND",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/eca1b43d-cbc1-4f71-b26d-c4add1decc54.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/India/StarSports1.in.png",
+ "created_at": "2026-05-06T13:27:55.894929+00:00",
+ "updated_at": "2026-05-29T08:39:40.19523+00:00"
+ },
+ {
+ "id": "57e316ac-f55a-4d98-93da-5ca698875913",
+ "name": "TNT Sports 1 HD Backup | UK",
+ "category": "sports",
+ "source_url": "https://cdn.dulo.tv/memfs/c015ed02-bdbd-471d-946a-c56646b47c62.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/UK/TNTSports1.uk.png",
+ "created_at": "2026-05-06T20:37:20.144135+00:00",
+ "updated_at": "2026-05-22T21:28:59.374074+00:00"
+ },
+ {
+ "id": "1c511cd8-ed3c-4a98-82e0-2bb9ebe8afe9",
+ "name": "Sky Sports Main Event SD | UK",
+ "category": "sports",
+ "source_url": "https://images.dulo.tv/memfs/c837ef60-8e7d-4cb6-8748-a7c720e83f8f.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/UK/SkySportsMainEvent.uk.png",
+ "created_at": "2026-05-16T13:07:32.818408+00:00",
+ "updated_at": "2026-05-16T13:07:58.89855+00:00"
+ },
+ {
+ "id": "8c60ce75-1f0d-41e7-997e-1e71444871f4",
+ "name": "Starz Encore Espanol HD | Latino",
+ "category": "movies",
+ "source_url": "https://hey.dulo.tv/memfs/5d4352d8-0e6e-465c-b65d-46adaaa69885.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://cdn.iptvboss.pro/logos/USA/StarzEncoreEspanol.us.png",
+ "created_at": "2026-05-20T02:08:19.805916+00:00",
+ "updated_at": "2026-05-31T04:35:58.163507+00:00"
+ },
+ {
+ "id": "95f88d4a-7349-4aec-b62c-06baaea70460",
+ "name": "ESPN Deportes HD | Latino",
+ "category": "sports",
+ "source_url": "https://bridge.dulo.tv/memfs/cd89307e-34c7-4efc-9e23-c6f1f60422ee.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://cdn.iptvboss.pro/logos/USA/ESPNDeportes.us.png",
+ "created_at": "2026-05-20T02:21:40.559962+00:00",
+ "updated_at": "2026-05-26T01:47:38.901139+00:00"
+ },
+ {
+ "id": "2af62785-0139-499a-8f4d-ac35b702ef06",
+ "name": "24/7 Peppa Pig",
+ "category": "kids",
+ "source_url": "https://bridge.dulo.tv/memfs/754b034f-6353-46a9-842e-4f717ec5d194.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://logos-world.net/wp-content/uploads/2024/01/Peppa-Pig-Logo-500x281.png",
+ "created_at": "2026-05-20T02:41:58.640365+00:00",
+ "updated_at": "2026-05-26T01:46:26.528306+00:00"
+ },
+ {
+ "id": "81a1ebb1-ba3e-4b13-a2a0-b6027575a815",
+ "name": "24/7 SpongeBob SquarePants",
+ "category": "kids",
+ "source_url": "https://bridge.dulo.tv/memfs/a2e386d8-c6f0-4e9e-ba66-b232195f45f9.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://1000logos.net/wp-content/uploads/2020/09/SpongeBob_SquarePants_logo.png",
+ "created_at": "2026-05-20T02:43:59.742145+00:00",
+ "updated_at": "2026-05-26T01:43:54.94617+00:00"
+ },
+ {
+ "id": "8c6bb9f3-548f-4e98-9ced-56178bb0beb3",
+ "name": "24/7 Bluey",
+ "category": "kids",
+ "source_url": "https://bridge.dulo.tv/memfs/79bcd103-4636-4a23-9372-f2a58fa49288.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://media.printables.com/media/prints/d35fce99-be65-436d-ae66-a833cc1a0754/images/10303645_a3372cce-e5aa-4679-9d42-52c1387e273c_0037f94f-e35b-498c-8100-1698b420d1fa/thumbs/cover/1200x630/png/bluey-logo.png",
+ "created_at": "2026-05-20T02:45:51.398708+00:00",
+ "updated_at": "2026-05-26T01:45:14.956531+00:00"
+ },
+ {
+ "id": "c5b8ec5e-bec1-4632-9dff-8bf1cb33b655",
+ "name": "Cinemax HD | USA",
+ "category": "movies",
+ "source_url": "https://hey.dulo.tv/memfs/b0bfb3af-3b3a-49bc-ace2-a0a1bdfb38e9.m3u8",
+ "epg_source_url": null,
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://iptvboss.xyz/logos/USA/CineMAX.us.png",
+ "created_at": "2026-05-20T16:32:20.354786+00:00",
+ "updated_at": "2026-05-31T04:37:54.058853+00:00"
+ },
+ {
+ "id": "8c4e39a2-745c-4bc5-a2da-a65cf91f8214",
+ "name": "Chicago Sports Network HD | USA",
+ "category": "sports",
+ "source_url": "https://bridge.dulo.tv/memfs/a45a9917-b8e0-4473-bb0e-1ec2377be9d7.m3u8",
+ "epg_source_url": "https://epg.pw/last/465058.html?lang=en",
+ "direct_source": true,
+ "sort_order": 0,
+ "logo_url": "https://cdn.iptvboss.pro/logos/USA/ChicagoSportsNetwork.us.png",
+ "created_at": "2026-05-29T23:43:02.847366+00:00",
+ "updated_at": "2026-05-29T23:43:42.123003+00:00"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/server/seed-data/sources/dulo.source.json b/server/seed-data/sources/dulo.source.json
new file mode 100644
index 00000000..5b055eda
--- /dev/null
+++ b/server/seed-data/sources/dulo.source.json
@@ -0,0 +1,1532 @@
+[
+ {
+ "_id": "dulo:0b56be49-557e-4315-9258-e9a0728ab608",
+ "source": "dulo",
+ "sourceChannelId": "0b56be49-557e-4315-9258-e9a0728ab608",
+ "name": "A&E East HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/AandENetwork.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/2afff205-c2ef-4621-a716-62e65d313b5c.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:29:23.917Z",
+ "sourceUpdatedAt": "2026-06-09T01:37:10.561Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:5e5ba7f3-6967-4504-9f26-b9f17ec42ead",
+ "source": "dulo",
+ "sourceChannelId": "5e5ba7f3-6967-4504-9f26-b9f17ec42ead",
+ "name": "ABC HD | USA",
+ "category": "news",
+ "groupKey": "news",
+ "groupLabel": "news",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/ABC%20%28copy%29.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/cf396710-6c4d-4c85-9021-df9c24fd6c0b.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:30:00.439Z",
+ "sourceUpdatedAt": "2026-05-29T05:39:06.728Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:e2ce7dba-a5e5-4b5c-a935-341f4a08ddeb",
+ "source": "dulo",
+ "sourceChannelId": "e2ce7dba-a5e5-4b5c-a935-341f4a08ddeb",
+ "name": "ACC Network HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/ACCNetwork.us.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/a2a7afae-9bc8-410f-8383-eb1e4117d2f9.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:30:40.651Z",
+ "sourceUpdatedAt": "2026-05-31T17:47:37.753Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:afa5347e-e2ab-403c-8670-61f8c4fd47f1",
+ "source": "dulo",
+ "sourceChannelId": "afa5347e-e2ab-403c-8670-61f8c4fd47f1",
+ "name": "Al Jazeera HD | USA",
+ "category": "news",
+ "groupKey": "news",
+ "groupLabel": "news",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/AlJazeera.us.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/bd712599-b656-49a7-9613-a60e8615bdf4.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:31:24.750Z",
+ "sourceUpdatedAt": "2026-05-29T05:41:06.853Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:e06e04f5-6e51-42f8-a3c0-7f7bcadb50be",
+ "source": "dulo",
+ "sourceChannelId": "e06e04f5-6e51-42f8-a3c0-7f7bcadb50be",
+ "name": "AMC HD | USA",
+ "category": "movies",
+ "groupKey": "movies",
+ "groupLabel": "movies",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/AMC.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/e60d4c96-718b-4d71-85f6-8787033cc435.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:32:01.414Z",
+ "sourceUpdatedAt": "2026-06-09T01:22:25.514Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:cd124020-4cc8-460f-b730-e0393015d164",
+ "source": "dulo",
+ "sourceChannelId": "cd124020-4cc8-460f-b730-e0393015d164",
+ "name": "Animal Planet HD | USA",
+ "category": "documentary",
+ "groupKey": "documentary",
+ "groupLabel": "documentary",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/AnimalPlanet.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/990a73ff-81d2-4222-92d0-6d1b4330245c.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:32:41.748Z",
+ "sourceUpdatedAt": "2026-05-31T04:05:36.889Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:bd829da5-d754-4e02-bdf5-65a14e666c43",
+ "source": "dulo",
+ "sourceChannelId": "bd829da5-d754-4e02-bdf5-65a14e666c43",
+ "name": "AT&T SportsNet Pittsburgh HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/ATTSportsNetPittsburgh.us.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/8cf8ced1-f653-4819-96ee-a25b182b2737.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:33:30.494Z",
+ "sourceUpdatedAt": "2026-06-09T05:02:01.773Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:35427165-289d-43b6-82bc-833c2e6e1341",
+ "source": "dulo",
+ "sourceChannelId": "35427165-289d-43b6-82bc-833c2e6e1341",
+ "name": "beIN Sports HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/beINSports.us.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/4631454f-f671-40f1-b766-6bed57ffec18.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:34:09.504Z",
+ "sourceUpdatedAt": "2026-06-09T05:04:14.176Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:a38ac31c-23fd-42f1-aa43-a5a30a2d7554",
+ "source": "dulo",
+ "sourceChannelId": "a38ac31c-23fd-42f1-aa43-a5a30a2d7554",
+ "name": "BET HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/BET.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/b8299a0d-07ec-40cb-b997-201434cbfefa.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:35:28.778Z",
+ "sourceUpdatedAt": "2026-06-09T01:23:14.374Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:15be82f9-7d1c-4035-8132-afcefe933063",
+ "source": "dulo",
+ "sourceChannelId": "15be82f9-7d1c-4035-8132-afcefe933063",
+ "name": "Big Ten Network HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/BigTen.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/b8299a0d-07ec-40cb-b997-201434cbfefa.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:36:15.830Z",
+ "sourceUpdatedAt": "2026-06-09T01:23:30.071Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:e18e2a96-41e4-4638-8402-f46ada39272d",
+ "source": "dulo",
+ "sourceChannelId": "e18e2a96-41e4-4638-8402-f46ada39272d",
+ "name": "Boomerang HD | USA",
+ "category": "kids",
+ "groupKey": "kids",
+ "groupLabel": "kids",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/Boomerang.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/b48d96c2-dd3c-4a6c-a5ae-41bf2220a61e.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:36:52.170Z",
+ "sourceUpdatedAt": "2026-05-31T04:03:25.431Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:9149b685-782f-4560-941a-7624d0857552",
+ "source": "dulo",
+ "sourceChannelId": "9149b685-782f-4560-941a-7624d0857552",
+ "name": "Bravo East HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/Bravo.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/ffe47a87-9e1f-4539-9879-e2fae54d3930.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:37:32.707Z",
+ "sourceUpdatedAt": "2026-06-09T01:26:00.849Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:e4e962a7-7d7c-4361-a271-fc6c52b19466",
+ "source": "dulo",
+ "sourceChannelId": "e4e962a7-7d7c-4361-a271-fc6c52b19466",
+ "name": "C-SPAN HD | USA",
+ "category": "news",
+ "groupKey": "news",
+ "groupLabel": "news",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/CSPAN.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/c2be2b4a-5f5f-43d9-acde-0110dec73251.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:38:22.183Z",
+ "sourceUpdatedAt": "2026-06-09T01:26:18.370Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:2e438a75-284a-4fe9-b64b-9fe3faf0765a",
+ "source": "dulo",
+ "sourceChannelId": "2e438a75-284a-4fe9-b64b-9fe3faf0765a",
+ "name": "Cartoon Network East HD | USA",
+ "category": "kids",
+ "groupKey": "kids",
+ "groupLabel": "kids",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/CartoonNetwork.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/0ca12fc0-73c4-4c1c-a259-82cd7d90f580.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:39:21.717Z",
+ "sourceUpdatedAt": "2026-05-31T02:31:08.839Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:5b2ceda1-36f9-4f47-aea9-fa8d04b3aa46",
+ "source": "dulo",
+ "sourceChannelId": "5b2ceda1-36f9-4f47-aea9-fa8d04b3aa46",
+ "name": "CMT HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/CMT.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/81cffdeb-5d41-4222-adb9-68f85d58094e.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:39:48.207Z",
+ "sourceUpdatedAt": "2026-06-09T05:38:52.839Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:a55f3e69-7941-4f12-9300-83a61f8f7339",
+ "source": "dulo",
+ "sourceChannelId": "a55f3e69-7941-4f12-9300-83a61f8f7339",
+ "name": "CNBC HD | USA",
+ "category": "news",
+ "groupKey": "news",
+ "groupLabel": "news",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/CNBC.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/f9d027af-a9d2-4c59-865e-7101f7602966.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:40:33.795Z",
+ "sourceUpdatedAt": "2026-06-09T01:27:07.764Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:d435a43b-8fee-4a39-86c6-dc24f494bdcc",
+ "source": "dulo",
+ "sourceChannelId": "d435a43b-8fee-4a39-86c6-dc24f494bdcc",
+ "name": "CNN HD | USA",
+ "category": "news",
+ "groupKey": "news",
+ "groupLabel": "news",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/CNN.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/bd76a4bc-4dec-4fc7-b200-e0b9f0302dbf.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:41:05.259Z",
+ "sourceUpdatedAt": "2026-06-09T01:27:22.208Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:3b00d176-d30d-4818-aeca-04f8bb3a8006",
+ "source": "dulo",
+ "sourceChannelId": "3b00d176-d30d-4818-aeca-04f8bb3a8006",
+ "name": "Comedy Central HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/ComedyCentral.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/e3d94c5a-a969-491a-9527-092b9ebb3424.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:42:46.336Z",
+ "sourceUpdatedAt": "2026-06-09T01:27:41.568Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:4e95c735-dda1-4717-87c1-7ffff63fddff",
+ "source": "dulo",
+ "sourceChannelId": "4e95c735-dda1-4717-87c1-7ffff63fddff",
+ "name": "Discovery Channel HD | USA",
+ "category": "documentary",
+ "groupKey": "documentary",
+ "groupLabel": "documentary",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/DiscoveryChannel.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/cc4fe5ad-82d3-46cf-b3ec-6214772ce8c9.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:43:28.368Z",
+ "sourceUpdatedAt": "2026-05-31T04:06:49.379Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:463e7954-6e71-4d25-b266-50fccc17a5bd",
+ "source": "dulo",
+ "sourceChannelId": "463e7954-6e71-4d25-b266-50fccc17a5bd",
+ "name": "Disney Channel HD | USA",
+ "category": "kids",
+ "groupKey": "kids",
+ "groupLabel": "kids",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/DisneyChannel.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/5ae3e3d6-e8de-4e6b-a1e4-44d08c52c1b9.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:44:11.811Z",
+ "sourceUpdatedAt": "2026-05-31T03:57:02.924Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:3664b2cb-31dc-4274-944b-73136a47f26e",
+ "source": "dulo",
+ "sourceChannelId": "3664b2cb-31dc-4274-944b-73136a47f26e",
+ "name": "Disney Jr HD | USA",
+ "category": "kids",
+ "groupKey": "kids",
+ "groupLabel": "kids",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/DisneyJunior.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/edce9dc6-ea78-484d-a6ae-8fc695680c5d.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:44:44.857Z",
+ "sourceUpdatedAt": "2026-05-31T04:41:37.631Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:b6fbc14a-bcdf-4c4d-88e1-78a94aec05f2",
+ "source": "dulo",
+ "sourceChannelId": "b6fbc14a-bcdf-4c4d-88e1-78a94aec05f2",
+ "name": "E! HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/EEntertainmentTelevision.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/d72e883f-c569-4c7f-9e6e-7466fcb77df6.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:45:58.725Z",
+ "sourceUpdatedAt": "2026-06-09T01:28:23.944Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:e84387d8-0d08-4766-93e0-8d3c39fb721c",
+ "source": "dulo",
+ "sourceChannelId": "e84387d8-0d08-4766-93e0-8d3c39fb721c",
+ "name": "epiX HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/Epix.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/1e79ae69-e06d-4ace-ae7b-c2d8769c2d46.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:46:34.951Z",
+ "sourceUpdatedAt": "2026-06-09T01:28:38.783Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:18037a7e-3daa-4ca5-8eb7-40ad0ba09776",
+ "source": "dulo",
+ "sourceChannelId": "18037a7e-3daa-4ca5-8eb7-40ad0ba09776",
+ "name": "ESPN 2 HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/ESPN2.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/762c0cd1-e3d5-4cc6-8b5c-fe058f74634c.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:47:11.201Z",
+ "sourceUpdatedAt": "2026-06-09T01:29:15.715Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:ef9447e6-bee7-4de3-80bc-9eb215a925df",
+ "source": "dulo",
+ "sourceChannelId": "ef9447e6-bee7-4de3-80bc-9eb215a925df",
+ "name": "ESPN HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/ESPN.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/22d6a3ff-655d-4a28-be98-b9705d80919b.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:48:55.100Z",
+ "sourceUpdatedAt": "2026-06-09T01:28:54.745Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:d80a1087-c4d1-4c0c-9f7d-1c639784ed7e",
+ "source": "dulo",
+ "sourceChannelId": "d80a1087-c4d1-4c0c-9f7d-1c639784ed7e",
+ "name": "ESPN News HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/ESPNEWS.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/a6f4cb11-c02f-4b0b-8fab-ba5147800479.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:49:37.250Z",
+ "sourceUpdatedAt": "2026-06-09T01:29:32.777Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:1263c734-c158-4936-969b-f46e5d21bef4",
+ "source": "dulo",
+ "sourceChannelId": "1263c734-c158-4936-969b-f46e5d21bef4",
+ "name": "ESPN U HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/ESPNU.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/69bb2fd6-b379-4612-971c-c613e0868ef2.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:50:12.026Z",
+ "sourceUpdatedAt": "2026-06-09T01:29:45.906Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:e51f0e58-0f0b-476e-b2a5-84033adcc2c2",
+ "source": "dulo",
+ "sourceChannelId": "e51f0e58-0f0b-476e-b2a5-84033adcc2c2",
+ "name": "Food Network HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/FoodNetwork.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/d5952b5e-8fd3-42f7-a6c9-468ea8cbec1d.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:51:00.312Z",
+ "sourceUpdatedAt": "2026-06-09T01:29:59.661Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:9e26f026-1202-4501-bb0a-faa9edd6e6a8",
+ "source": "dulo",
+ "sourceChannelId": "9e26f026-1202-4501-bb0a-faa9edd6e6a8",
+ "name": "Fox 5 NY HD | USA",
+ "category": "news",
+ "groupKey": "news",
+ "groupLabel": "news",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/FoxEast_WNYW.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/f022aa37-6621-4c5a-b321-4a204c75743a.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:51:42.636Z",
+ "sourceUpdatedAt": "2026-06-09T01:30:12.769Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:b97baa93-8be4-4dd7-b7bf-29f24fe401f2",
+ "source": "dulo",
+ "sourceChannelId": "b97baa93-8be4-4dd7-b7bf-29f24fe401f2",
+ "name": "Fox Business HD | USA",
+ "category": "news",
+ "groupKey": "news",
+ "groupLabel": "news",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/FoxBusiness.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/9509f30b-0b7a-4761-b18d-cb47644a414b.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:53:01.618Z",
+ "sourceUpdatedAt": "2026-06-09T01:30:26.379Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:509cda12-8077-4c82-a2aa-10ba512fcc75",
+ "source": "dulo",
+ "sourceChannelId": "509cda12-8077-4c82-a2aa-10ba512fcc75",
+ "name": "Fox News HD | USA",
+ "category": "news",
+ "groupKey": "news",
+ "groupLabel": "news",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/FoxNewsChannel.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/f2134c41-965e-4ffa-9d0b-3a8fb0b576e7.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:53:51.758Z",
+ "sourceUpdatedAt": "2026-06-09T01:30:42.970Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:4567e88a-0e47-4e61-ab50-6d9ff92252e2",
+ "source": "dulo",
+ "sourceChannelId": "4567e88a-0e47-4e61-ab50-6d9ff92252e2",
+ "name": "Fox Sports 1 HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/FoxSports1.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/124a2e29-8677-48cc-b8be-2b00f748e497.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:55:22.886Z",
+ "sourceUpdatedAt": "2026-06-09T01:30:55.020Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:22483c87-bd9c-4880-8970-98a532680d40",
+ "source": "dulo",
+ "sourceChannelId": "22483c87-bd9c-4880-8970-98a532680d40",
+ "name": "Fox Sports 2 HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/FoxSports2.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/d1f8a09a-0c14-4445-a3a4-9b27982da2fb.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:55:59.774Z",
+ "sourceUpdatedAt": "2026-06-09T01:31:06.611Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:3118f2f8-b956-4214-9c58-b07e78a06b21",
+ "source": "dulo",
+ "sourceChannelId": "3118f2f8-b956-4214-9c58-b07e78a06b21",
+ "name": "FreeForm HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/Freeform.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/e7af2de6-2703-47dd-9af1-10b3048b1e9d.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:56:52.600Z",
+ "sourceUpdatedAt": "2026-06-09T01:31:21.566Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:08369fe5-f318-4a89-982d-9f63c537302e",
+ "source": "dulo",
+ "sourceChannelId": "08369fe5-f318-4a89-982d-9f63c537302e",
+ "name": "FX HD | USA",
+ "category": "movies",
+ "groupKey": "movies",
+ "groupLabel": "movies",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/FX.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/8a3432fb-04a7-4ade-a28f-5c1a0cc3f68e.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:58:31.867Z",
+ "sourceUpdatedAt": "2026-05-31T04:12:55.759Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:da593975-ed36-41eb-889d-defc075ac64a",
+ "source": "dulo",
+ "sourceChannelId": "da593975-ed36-41eb-889d-defc075ac64a",
+ "name": "Hallmark Family HD | USA",
+ "category": "movies",
+ "groupKey": "movies",
+ "groupLabel": "movies",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/HallmarkFamily.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/2fab5943-6302-4d69-beaf-57c64e65a3d5.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T02:59:20.663Z",
+ "sourceUpdatedAt": "2026-05-31T04:14:47.223Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:19395309-c667-409d-9104-3196557a6c2a",
+ "source": "dulo",
+ "sourceChannelId": "19395309-c667-409d-9104-3196557a6c2a",
+ "name": "HBO 2 HD | USA",
+ "category": "movies",
+ "groupKey": "movies",
+ "groupLabel": "movies",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/HBO2.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/af8aa9b5-ddaf-483f-bbbc-34bc24d180c1.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:02:20.451Z",
+ "sourceUpdatedAt": "2026-05-31T04:16:00.039Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:f9a9b988-868e-4014-8bcd-fe32da862d39",
+ "source": "dulo",
+ "sourceChannelId": "f9a9b988-868e-4014-8bcd-fe32da862d39",
+ "name": "HBO Family HD | USA",
+ "category": "movies",
+ "groupKey": "movies",
+ "groupLabel": "movies",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/HBOFamily.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/4024db2b-e32f-49e2-936c-db299b7b3c70.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:06:01.005Z",
+ "sourceUpdatedAt": "2026-05-31T04:17:20.507Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:4499e99d-8e5b-4087-9d51-9c61a820ca02",
+ "source": "dulo",
+ "sourceChannelId": "4499e99d-8e5b-4087-9d51-9c61a820ca02",
+ "name": "HBO HD | USA",
+ "category": "movies",
+ "groupKey": "movies",
+ "groupLabel": "movies",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/HBO.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/40b05b79-ee37-4d80-81be-fecb99539003.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:06:42.119Z",
+ "sourceUpdatedAt": "2026-06-01T01:21:54.617Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:fabb4415-d644-44dd-b083-dab8c77d8833",
+ "source": "dulo",
+ "sourceChannelId": "fabb4415-d644-44dd-b083-dab8c77d8833",
+ "name": "HBO Latino HD | USA",
+ "category": "movies",
+ "groupKey": "movies",
+ "groupLabel": "movies",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/HBOLatino.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/1af71434-74c1-4e5d-a1f9-ec8b005c6bbd.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:07:23.376Z",
+ "sourceUpdatedAt": "2026-06-09T01:54:20.306Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:8cf4517b-a7c4-4e97-b2de-7ff3ada11543",
+ "source": "dulo",
+ "sourceChannelId": "8cf4517b-a7c4-4e97-b2de-7ff3ada11543",
+ "name": "HBO Signature HD | USA",
+ "category": "movies",
+ "groupKey": "movies",
+ "groupLabel": "movies",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/HBOSignature.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/51d76cab-aff4-435a-ae57-88c108092ba7.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:08:02.732Z",
+ "sourceUpdatedAt": "2026-05-31T04:21:39.827Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:72b7a1df-54ef-4ca1-9c82-e526977775c5",
+ "source": "dulo",
+ "sourceChannelId": "72b7a1df-54ef-4ca1-9c82-e526977775c5",
+ "name": "HGTV HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/HGTV.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/6ed802f7-51b2-459d-8f90-4198bc46f61f.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:09:02.609Z",
+ "sourceUpdatedAt": "2026-06-09T01:32:17.646Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:6f629954-45bb-4263-a58e-ba47f9277ef5",
+ "source": "dulo",
+ "sourceChannelId": "6f629954-45bb-4263-a58e-ba47f9277ef5",
+ "name": "History HD | USA",
+ "category": "documentary",
+ "groupKey": "documentary",
+ "groupLabel": "documentary",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/History.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/732bd308-3c5e-4260-9626-9dee58f3990f.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:09:32.616Z",
+ "sourceUpdatedAt": "2026-06-09T01:32:35.637Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:50fea76c-3c5b-45b9-a8c2-31093fd6541b",
+ "source": "dulo",
+ "sourceChannelId": "50fea76c-3c5b-45b9-a8c2-31093fd6541b",
+ "name": "Investigation Discovery HD | USA",
+ "category": "documentary",
+ "groupKey": "documentary",
+ "groupLabel": "documentary",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/InvestigationDiscovery.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/cc30205b-d5b7-47ca-bf66-784f4dbe9bdd.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:10:17.256Z",
+ "sourceUpdatedAt": "2026-05-31T04:09:42.485Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:dc23c106-df27-4364-9dcc-7d3a30ad44e2",
+ "source": "dulo",
+ "sourceChannelId": "dc23c106-df27-4364-9dcc-7d3a30ad44e2",
+ "name": "Lifetime HD | TV",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/Lifetime.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/c00b0f90-4a1b-4846-b676-c2eeff8a01bf.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:11:13.848Z",
+ "sourceUpdatedAt": "2026-06-09T01:39:07.679Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:d0bc8a90-1bd2-4e7f-8875-0b2be40888f8",
+ "source": "dulo",
+ "sourceChannelId": "d0bc8a90-1bd2-4e7f-8875-0b2be40888f8",
+ "name": "Magnolia Network HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/MagnoliaNetwork.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/0a16d94a-fda9-4d63-bb7a-c7003f7336c8.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:11:51.707Z",
+ "sourceUpdatedAt": "2026-06-09T01:39:24.083Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:88242062-2bfd-4547-9043-b2189399f43e",
+ "source": "dulo",
+ "sourceChannelId": "88242062-2bfd-4547-9043-b2189399f43e",
+ "name": "MLB Network HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/MLBNetwork.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/a185d75f-7635-4b51-bb42-e6d776917d14.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:18:32.884Z",
+ "sourceUpdatedAt": "2026-06-09T01:39:40.798Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:ae1bbb1c-fce3-4768-b13a-1417b27e0c76",
+ "source": "dulo",
+ "sourceChannelId": "ae1bbb1c-fce3-4768-b13a-1417b27e0c76",
+ "name": "MSG HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/MSG.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/84239837-d11a-43d7-a645-923e1554e7ba.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:19:11.991Z",
+ "sourceUpdatedAt": "2026-06-09T01:40:43.439Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:a3d24518-0ec0-4a60-bb74-43e62f923277",
+ "source": "dulo",
+ "sourceChannelId": "a3d24518-0ec0-4a60-bb74-43e62f923277",
+ "name": "MSNBC HD | USA",
+ "category": "news",
+ "groupKey": "news",
+ "groupLabel": "news",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/MSNBC.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/e77e70b2-f513-4f5c-ba45-42743dbc5453.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:19:51.602Z",
+ "sourceUpdatedAt": "2026-06-09T01:40:59.649Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:96d5474d-facb-48f0-875e-6e1836ecf5c2",
+ "source": "dulo",
+ "sourceChannelId": "96d5474d-facb-48f0-875e-6e1836ecf5c2",
+ "name": "MTV HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/MTV.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/4484de6d-491b-4a3d-967f-08e68b63b174.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:20:42.824Z",
+ "sourceUpdatedAt": "2026-06-09T01:41:14.485Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:2798d6d7-b2e5-46f0-8753-7be9c3cfcf22",
+ "source": "dulo",
+ "sourceChannelId": "2798d6d7-b2e5-46f0-8753-7be9c3cfcf22",
+ "name": "National Geographic HD | USA",
+ "category": "documentary",
+ "groupKey": "documentary",
+ "groupLabel": "documentary",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/NationalGeographic.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/fa02d70f-4214-4f94-ac71-d612b90468a6.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:22:45.607Z",
+ "sourceUpdatedAt": "2026-06-09T01:41:38.163Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:2d707c68-d53a-4174-9b3a-78e44b300e63",
+ "source": "dulo",
+ "sourceChannelId": "2d707c68-d53a-4174-9b3a-78e44b300e63",
+ "name": "NBA TV HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/NBAtv.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/0d6106f5-50e4-43f1-8ab5-431dac6fc4c0.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:23:17.032Z",
+ "sourceUpdatedAt": "2026-06-09T01:42:00.396Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:671a4cce-efa5-4254-a2fc-d17278e3ded3",
+ "source": "dulo",
+ "sourceChannelId": "671a4cce-efa5-4254-a2fc-d17278e3ded3",
+ "name": "NESN HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/NESN.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/8bd363bf-6fb2-4315-ac1d-1c1952f7f4cd.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:24:12.362Z",
+ "sourceUpdatedAt": "2026-06-09T01:44:35.991Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:312c8261-c482-4346-8437-83eb9436c91b",
+ "source": "dulo",
+ "sourceChannelId": "312c8261-c482-4346-8437-83eb9436c91b",
+ "name": "NFL Network HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/NFLNetwork.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/15816015-c1d0-47da-81e9-ae68e9d2b67f.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:25:18.812Z",
+ "sourceUpdatedAt": "2026-06-09T01:44:48.153Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:1e89de9c-999b-4193-8414-a444631e3728",
+ "source": "dulo",
+ "sourceChannelId": "1e89de9c-999b-4193-8414-a444631e3728",
+ "name": "NHL Network HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/NHLNetwork.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/4546b90f-28a7-4f12-88c3-856df043e237.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:25:52.205Z",
+ "sourceUpdatedAt": "2026-06-09T01:45:04.240Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:6b75e415-e3a5-4815-9cf2-237e7f11ae2e",
+ "source": "dulo",
+ "sourceChannelId": "6b75e415-e3a5-4815-9cf2-237e7f11ae2e",
+ "name": "Nickelodeon East HD | USA",
+ "category": "kids",
+ "groupKey": "kids",
+ "groupLabel": "kids",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/Nickelodeon.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/59c180f4-1bdc-488c-8e29-9e051f477392.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:26:51.363Z",
+ "sourceUpdatedAt": "2026-05-31T04:00:33.085Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:861952e3-3157-4635-9944-d2d074a817ab",
+ "source": "dulo",
+ "sourceChannelId": "861952e3-3157-4635-9944-d2d074a817ab",
+ "name": "Nicktoons HD | USA",
+ "category": "kids",
+ "groupKey": "kids",
+ "groupLabel": "kids",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/Nicktoons.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/4108a8ad-602e-4a25-9f71-21cd1338a403.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:27:22.543Z",
+ "sourceUpdatedAt": "2026-05-31T04:02:04.205Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:27dfd93f-08da-4a65-8d9f-b174f7062d79",
+ "source": "dulo",
+ "sourceChannelId": "27dfd93f-08da-4a65-8d9f-b174f7062d79",
+ "name": "OWN HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/OWN.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/ca273200-b240-48cc-b03a-31da3e2e7d75.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:28:06.545Z",
+ "sourceUpdatedAt": "2026-06-09T01:47:32.812Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:be1f5f0e-80c8-4fb1-a2ff-fe6a6e55e2de",
+ "source": "dulo",
+ "sourceChannelId": "be1f5f0e-80c8-4fb1-a2ff-fe6a6e55e2de",
+ "name": "Paramount Network HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/ParamountNetwork.us.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/e8226677-87ba-4f1f-ba23-7b992c6336fb.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:28:38.876Z",
+ "sourceUpdatedAt": "2026-05-29T07:36:55.489Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:116bab23-7ece-4230-94d1-796631e244c5",
+ "source": "dulo",
+ "sourceChannelId": "116bab23-7ece-4230-94d1-796631e244c5",
+ "name": "Showtime Extreme HD | USA",
+ "category": "movies",
+ "groupKey": "movies",
+ "groupLabel": "movies",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/ShowtimeExtreme.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/ba77ea81-a931-48aa-964e-6536852f60cb.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:30:39.298Z",
+ "sourceUpdatedAt": "2026-05-31T04:24:31.087Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:62f075b0-cdd3-4fdb-b0e9-e32c49285836",
+ "source": "dulo",
+ "sourceChannelId": "62f075b0-cdd3-4fdb-b0e9-e32c49285836",
+ "name": "Sony Movie Channel HD | USA",
+ "category": "movies",
+ "groupKey": "movies",
+ "groupLabel": "movies",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/SonyMovieChannel.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/3d0c8718-92d6-4734-8448-863a9481b8d7.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:37:54.799Z",
+ "sourceUpdatedAt": "2026-06-09T02:04:09.488Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:b027a9ab-e147-405e-b77c-868f2f225300",
+ "source": "dulo",
+ "sourceChannelId": "b027a9ab-e147-405e-b77c-868f2f225300",
+ "name": "Spectrum SportsNet LA Dodgers HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/SpectrumSportsNetLADodgers.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/4a0c2a1f-bbb5-4cf0-998a-31b2eeb5869a.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:39:06.913Z",
+ "sourceUpdatedAt": "2026-06-09T16:23:36.864Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:5c83b496-df9e-4dae-9554-d93cd482177a",
+ "source": "dulo",
+ "sourceChannelId": "5c83b496-df9e-4dae-9554-d93cd482177a",
+ "name": "Starz Cinema HD | USA",
+ "category": "movies",
+ "groupKey": "movies",
+ "groupLabel": "movies",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/StarzCinema.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/2df70247-3213-47e3-8553-bc8c28d0c67f.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:40:15.481Z",
+ "sourceUpdatedAt": "2026-05-31T04:29:37.115Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:667683d0-e7d7-4f10-af12-5174ee74d8ed",
+ "source": "dulo",
+ "sourceChannelId": "667683d0-e7d7-4f10-af12-5174ee74d8ed",
+ "name": "Starz East HD | USA",
+ "category": "movies",
+ "groupKey": "movies",
+ "groupLabel": "movies",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/Starz.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/8789fb20-a653-465f-b491-937724e3765b.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:40:41.308Z",
+ "sourceUpdatedAt": "2026-05-31T04:30:59.041Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:126761fd-9c50-46d7-9821-a7503528fe7f",
+ "source": "dulo",
+ "sourceChannelId": "126761fd-9c50-46d7-9821-a7503528fe7f",
+ "name": "Starz Encore HD | USA",
+ "category": "movies",
+ "groupKey": "movies",
+ "groupLabel": "movies",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/StarzEncore.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/5e336b09-a46c-4a0f-b72f-886c30dc92cb.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:41:06.243Z",
+ "sourceUpdatedAt": "2026-05-31T04:32:13.702Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:11d8bbd6-b65b-4138-ac22-0194346db38e",
+ "source": "dulo",
+ "sourceChannelId": "11d8bbd6-b65b-4138-ac22-0194346db38e",
+ "name": "SyFy HD | USA",
+ "category": "movies",
+ "groupKey": "movies",
+ "groupLabel": "movies",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/Syfy.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/010d6d1d-a8ed-42dc-abc5-82124ede2a59.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:41:42.962Z",
+ "sourceUpdatedAt": "2026-05-31T04:34:35.485Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:b1428e38-1dd2-41ef-94b6-f2f24b435a63",
+ "source": "dulo",
+ "sourceChannelId": "b1428e38-1dd2-41ef-94b6-f2f24b435a63",
+ "name": "Tennis Channel HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/TennisChannel.us.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/632c60eb-7798-4695-b617-0271e22b47d1.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:42:21.397Z",
+ "sourceUpdatedAt": "2026-05-29T07:51:03.463Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:5d135d5f-23f5-4c77-881e-dd074ad1844e",
+ "source": "dulo",
+ "sourceChannelId": "5d135d5f-23f5-4c77-881e-dd074ad1844e",
+ "name": "The Weather Channel HD | USA",
+ "category": "news",
+ "groupKey": "news",
+ "groupLabel": "news",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/TheWeatherChannel.us.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/faa13ade-5cc2-451a-86b2-b22de56da424.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:43:01.773Z",
+ "sourceUpdatedAt": "2026-05-29T07:53:17.451Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:ac80172e-e8ff-4a06-b956-bc25bf3b519f",
+ "source": "dulo",
+ "sourceChannelId": "ac80172e-e8ff-4a06-b956-bc25bf3b519f",
+ "name": "TNT HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/TNT.us.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/875059b4-6cfa-4be2-9e86-159be43583e8.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:43:36.092Z",
+ "sourceUpdatedAt": "2026-05-29T07:54:14.862Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:f822a7fa-ed38-4a95-aa8f-69ad98fcbce7",
+ "source": "dulo",
+ "sourceChannelId": "f822a7fa-ed38-4a95-aa8f-69ad98fcbce7",
+ "name": "TruTV HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/truTV.us.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/2965b183-a648-4866-b598-3e193785d33f.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:44:42.470Z",
+ "sourceUpdatedAt": "2026-05-29T07:55:23.124Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:74d709fa-01e7-4499-bc3e-d1a6b3540948",
+ "source": "dulo",
+ "sourceChannelId": "74d709fa-01e7-4499-bc3e-d1a6b3540948",
+ "name": "USA Network HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/USANetwork.us.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/744d72b2-0d9c-45c0-9824-6f030ad06ad3.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:45:16.676Z",
+ "sourceUpdatedAt": "2026-05-29T07:56:09.163Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:344d07b6-4b72-4aae-94ce-c1a508167d3d",
+ "source": "dulo",
+ "sourceChannelId": "344d07b6-4b72-4aae-94ce-c1a508167d3d",
+ "name": "VH1 HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/VH1.us.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/34ae4c6e-82c1-4ef4-b871-ac82bf960154.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:46:00.402Z",
+ "sourceUpdatedAt": "2026-05-29T07:57:09.238Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:c7e4a13a-142f-433c-8fe3-55cbae1e67d5",
+ "source": "dulo",
+ "sourceChannelId": "c7e4a13a-142f-433c-8fe3-55cbae1e67d5",
+ "name": "Willow Cricket HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/WillowCricket.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/7cdbfb8d-c614-4f92-870c-675d5d890d5e.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T03:46:35.984Z",
+ "sourceUpdatedAt": "2026-06-09T05:06:34.327Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:36c4d9d1-4d43-45f7-8477-c1bfbe0ad217",
+ "source": "dulo",
+ "sourceChannelId": "36c4d9d1-4d43-45f7-8477-c1bfbe0ad217",
+ "name": "Sportsnet East HD | CA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/Canada/SportsnetEast.ca.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/0c29406e-bf63-4393-8b07-cf594efdbc3c.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T04:27:41.947Z",
+ "sourceUpdatedAt": "2026-06-09T05:25:35.922Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:688ddade-4c5f-4b1a-9fe3-b3565475b5dd",
+ "source": "dulo",
+ "sourceChannelId": "688ddade-4c5f-4b1a-9fe3-b3565475b5dd",
+ "name": "TSN 1 HD | CA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/Canada/TSN1.ca.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/3929afa8-022d-4bda-af5c-aa39c33b05de.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T04:28:36.295Z",
+ "sourceUpdatedAt": "2026-05-29T08:04:59.296Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:e7756305-b952-456a-8aa8-8253fcb77209",
+ "source": "dulo",
+ "sourceChannelId": "e7756305-b952-456a-8aa8-8253fcb77209",
+ "name": "TSN 2 HD | CA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/Canada/TSN2.ca.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/79e4cfd2-676b-48de-b035-54f5ef7dfa28.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T04:31:08.698Z",
+ "sourceUpdatedAt": "2026-05-29T08:06:06.135Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:cf99e9c5-9cb8-41a9-81fd-1e27e56ecead",
+ "source": "dulo",
+ "sourceChannelId": "cf99e9c5-9cb8-41a9-81fd-1e27e56ecead",
+ "name": "TSN 3 HD | CA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/Canada/TSN3.ca.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/8176f064-c2d0-4cba-9899-c533e80d68a8.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-21T04:31:59.186Z",
+ "sourceUpdatedAt": "2026-05-29T08:07:13.796Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:83fd2f2e-249f-43b4-b09b-dc9de11137f9",
+ "source": "dulo",
+ "sourceChannelId": "83fd2f2e-249f-43b4-b09b-dc9de11137f9",
+ "name": "GSN HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/GameShowNetworkPacific.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/d8f0da98-667f-4ff1-b090-79ec3df30a97.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-24T20:00:02.586Z",
+ "sourceUpdatedAt": "2026-06-09T01:31:57.958Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:c4dda23a-c202-4e2c-8be8-f5ed5e748534",
+ "source": "dulo",
+ "sourceChannelId": "c4dda23a-c202-4e2c-8be8-f5ed5e748534",
+ "name": "TNT Sports 1 HD | UK",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/UK/TNTSports1.uk.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/e773710d-d3e5-4e03-8470-3a6070cf9285.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-25T00:36:12.494Z",
+ "sourceUpdatedAt": "2026-05-29T08:09:26.192Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:7f37f6cb-d5c2-4003-87e2-780c942d1f8d",
+ "source": "dulo",
+ "sourceChannelId": "7f37f6cb-d5c2-4003-87e2-780c942d1f8d",
+ "name": "TNT Sports 2 HD | UK",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/UK/TNTSports2.uk.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/4297a668-7c2f-48dd-b6f3-d07d539c3b26.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-25T00:43:02.010Z",
+ "sourceUpdatedAt": "2026-05-29T08:10:01.685Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:e1d60af0-164b-41bf-abd4-1710aed0911b",
+ "source": "dulo",
+ "sourceChannelId": "e1d60af0-164b-41bf-abd4-1710aed0911b",
+ "name": "NBC Sports Bay Area HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/NBCSportsBayArea.us.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/713cb02d-2bb3-4259-9bb1-f1b8174a16a1.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-26T17:43:41.261Z",
+ "sourceUpdatedAt": "2026-05-29T08:11:10.230Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:682e3578-7e54-4e67-b856-79052ee22d8e",
+ "source": "dulo",
+ "sourceChannelId": "682e3578-7e54-4e67-b856-79052ee22d8e",
+ "name": "NBC Sports California HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/NBCSportsCalifornia.us.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/153aceee-ff14-495c-8620-9cfe9e9e505b.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-26T17:50:35.097Z",
+ "sourceUpdatedAt": "2026-05-29T08:12:08.901Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:a4c38821-8716-4e9e-b755-9524fbae8886",
+ "source": "dulo",
+ "sourceChannelId": "a4c38821-8716-4e9e-b755-9524fbae8886",
+ "name": "Travel Channel HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/TravelChannel.us.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/6642f732-1ad7-4273-b30a-d12cd639f99c.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-26T18:19:46.814Z",
+ "sourceUpdatedAt": "2026-05-29T08:13:22.968Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:84522924-f79c-4cd4-bb22-df9f19502ce7",
+ "source": "dulo",
+ "sourceChannelId": "84522924-f79c-4cd4-bb22-df9f19502ce7",
+ "name": "Destination America HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/DestinationAmerica.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/685d35e8-8e90-4ceb-9241-a18a17056b34.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-26T18:22:32.872Z",
+ "sourceUpdatedAt": "2026-06-09T01:27:57.588Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:a06ea1dd-0304-4325-9f15-5f5e6969849f",
+ "source": "dulo",
+ "sourceChannelId": "a06ea1dd-0304-4325-9f15-5f5e6969849f",
+ "name": "fyi HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/FYIChannel.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/4fd9553b-efc3-4b15-8c81-c27d861fa94d.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-26T18:26:25.132Z",
+ "sourceUpdatedAt": "2026-06-09T01:31:39.413Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:df2cc909-cdda-4061-bebe-b31d210fa62c",
+ "source": "dulo",
+ "sourceChannelId": "df2cc909-cdda-4061-bebe-b31d210fa62c",
+ "name": "NBC 4 HD | US",
+ "category": "news",
+ "groupKey": "news",
+ "groupLabel": "news",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/NBCWNBC.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/fe609bb0-14f7-4e69-9c1d-66afa127d03d.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-26T18:31:17.645Z",
+ "sourceUpdatedAt": "2026-06-09T01:43:12.336Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:4f3ddbdb-38ea-4853-8fad-404f0899b1dd",
+ "source": "dulo",
+ "sourceChannelId": "4f3ddbdb-38ea-4853-8fad-404f0899b1dd",
+ "name": "CBS 2 NY HD | USA",
+ "category": "news",
+ "groupKey": "news",
+ "groupLabel": "news",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/WCBS.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/bbf679fc-bf77-4ac4-a988-b36195bd1e31.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-26T18:33:45.721Z",
+ "sourceUpdatedAt": "2026-06-09T01:26:38.492Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:0e5da8bf-5203-4abf-8dd1-ed7e28b33f10",
+ "source": "dulo",
+ "sourceChannelId": "0e5da8bf-5203-4abf-8dd1-ed7e28b33f10",
+ "name": "Smithsonian Channel HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/smithsonianchannel.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/3c03d782-4a10-43e4-8209-b23ee2759e88.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-03-26T18:40:30.072Z",
+ "sourceUpdatedAt": "2026-05-29T08:18:37.144Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:5e675be4-89a2-43d7-8917-3c6e78acc30b",
+ "source": "dulo",
+ "sourceChannelId": "5e675be4-89a2-43d7-8917-3c6e78acc30b",
+ "name": "TSN 4 HD | CA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/Canada/TSN4.ca.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/7c780698-133a-48ef-bdbf-585c4b7ab6c9.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-04-07T21:21:50.538Z",
+ "sourceUpdatedAt": "2026-05-29T08:19:43.457Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:aaf601ce-6337-4f14-ac22-e2118f49072c",
+ "source": "dulo",
+ "sourceChannelId": "aaf601ce-6337-4f14-ac22-e2118f49072c",
+ "name": "TSN 5 HD | CA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/Canada/TSN5.ca.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/48a842fe-0f1d-4daf-ad63-c930ac09d1bf.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-04-07T21:23:40.840Z",
+ "sourceUpdatedAt": "2026-05-29T08:20:29.565Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:98a9cd29-0a98-4cb1-8334-18067b9d47ac",
+ "source": "dulo",
+ "sourceChannelId": "98a9cd29-0a98-4cb1-8334-18067b9d47ac",
+ "name": "CBS Sports HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/CBSSportsNetwork.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/46ea23e8-f030-41d4-a5f9-19cb3c5adc92.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-04-07T21:50:54.367Z",
+ "sourceUpdatedAt": "2026-06-09T01:26:49.254Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:94140129-0daf-4343-ab07-50fee471c779",
+ "source": "dulo",
+ "sourceChannelId": "94140129-0daf-4343-ab07-50fee471c779",
+ "name": "TBS HD | USA",
+ "category": "entertainment",
+ "groupKey": "entertainment",
+ "groupLabel": "entertainment",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/TBS.us.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/cffea07a-4d5c-4764-8361-126aa6d28c84.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-05-02T19:41:54.342Z",
+ "sourceUpdatedAt": "2026-05-29T08:22:54.580Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:9050e056-6c10-4f86-9a75-a65fe8598e01",
+ "source": "dulo",
+ "sourceChannelId": "9050e056-6c10-4f86-9a75-a65fe8598e01",
+ "name": "Star Sports 1 FHD | IND",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/India/StarSports1.in.png",
+ "streamEntryUrl": "https://images.dulo.tv/memfs/eca1b43d-cbc1-4f71-b26d-c4add1decc54.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-05-06T13:27:55.894Z",
+ "sourceUpdatedAt": "2026-05-29T08:39:40.195Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:57e316ac-f55a-4d98-93da-5ca698875913",
+ "source": "dulo",
+ "sourceChannelId": "57e316ac-f55a-4d98-93da-5ca698875913",
+ "name": "TNT Sports 1 HD Backup | UK",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://iptvboss.xyz/logos/UK/TNTSports1.uk.png",
+ "streamEntryUrl": "https://cdn.dulo.tv/memfs/c015ed02-bdbd-471d-946a-c56646b47c62.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-05-06T20:37:20.144Z",
+ "sourceUpdatedAt": "2026-05-22T21:28:59.374Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:8c60ce75-1f0d-41e7-997e-1e71444871f4",
+ "source": "dulo",
+ "sourceChannelId": "8c60ce75-1f0d-41e7-997e-1e71444871f4",
+ "name": "Starz Encore Espanol HD | Latino",
+ "category": "movies",
+ "groupKey": "movies",
+ "groupLabel": "movies",
+ "logoUrl": "https://cdn.iptvboss.pro/logos/USA/StarzEncoreEspanol.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/5d4352d8-0e6e-465c-b65d-46adaaa69885.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-05-20T02:08:19.805Z",
+ "sourceUpdatedAt": "2026-05-31T04:35:58.163Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:95f88d4a-7349-4aec-b62c-06baaea70460",
+ "source": "dulo",
+ "sourceChannelId": "95f88d4a-7349-4aec-b62c-06baaea70460",
+ "name": "ESPN Deportes HD | Latino",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://cdn.iptvboss.pro/logos/USA/ESPNDeportes.us.png",
+ "streamEntryUrl": "https://bridge.dulo.tv/memfs/cd89307e-34c7-4efc-9e23-c6f1f60422ee.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-05-20T02:21:40.559Z",
+ "sourceUpdatedAt": "2026-05-26T01:47:38.901Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:2af62785-0139-499a-8f4d-ac35b702ef06",
+ "source": "dulo",
+ "sourceChannelId": "2af62785-0139-499a-8f4d-ac35b702ef06",
+ "name": "24/7 Peppa Pig",
+ "category": "kids",
+ "groupKey": "kids",
+ "groupLabel": "kids",
+ "logoUrl": "https://logos-world.net/wp-content/uploads/2024/01/Peppa-Pig-Logo-500x281.png",
+ "streamEntryUrl": "https://bridge.dulo.tv/memfs/754b034f-6353-46a9-842e-4f717ec5d194.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-05-20T02:41:58.640Z",
+ "sourceUpdatedAt": "2026-05-26T01:46:26.528Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:81a1ebb1-ba3e-4b13-a2a0-b6027575a815",
+ "source": "dulo",
+ "sourceChannelId": "81a1ebb1-ba3e-4b13-a2a0-b6027575a815",
+ "name": "24/7 SpongeBob SquarePants",
+ "category": "kids",
+ "groupKey": "kids",
+ "groupLabel": "kids",
+ "logoUrl": "https://1000logos.net/wp-content/uploads/2020/09/SpongeBob_SquarePants_logo.png",
+ "streamEntryUrl": "https://bridge.dulo.tv/memfs/a2e386d8-c6f0-4e9e-ba66-b232195f45f9.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-05-20T02:43:59.742Z",
+ "sourceUpdatedAt": "2026-05-26T01:43:54.946Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:8c6bb9f3-548f-4e98-9ced-56178bb0beb3",
+ "source": "dulo",
+ "sourceChannelId": "8c6bb9f3-548f-4e98-9ced-56178bb0beb3",
+ "name": "24/7 Bluey",
+ "category": "kids",
+ "groupKey": "kids",
+ "groupLabel": "kids",
+ "logoUrl": "https://media.printables.com/media/prints/d35fce99-be65-436d-ae66-a833cc1a0754/images/10303645_a3372cce-e5aa-4679-9d42-52c1387e273c_0037f94f-e35b-498c-8100-1698b420d1fa/thumbs/cover/1200x630/png/bluey-logo.png",
+ "streamEntryUrl": "https://bridge.dulo.tv/memfs/79bcd103-4636-4a23-9372-f2a58fa49288.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-05-20T02:45:51.398Z",
+ "sourceUpdatedAt": "2026-05-26T01:45:14.956Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:c5b8ec5e-bec1-4632-9dff-8bf1cb33b655",
+ "source": "dulo",
+ "sourceChannelId": "c5b8ec5e-bec1-4632-9dff-8bf1cb33b655",
+ "name": "Cinemax HD | USA",
+ "category": "movies",
+ "groupKey": "movies",
+ "groupLabel": "movies",
+ "logoUrl": "https://iptvboss.xyz/logos/USA/CineMAX.us.png",
+ "streamEntryUrl": "https://hey.dulo.tv/memfs/b0bfb3af-3b3a-49bc-ace2-a0a1bdfb38e9.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-05-20T16:32:20.354Z",
+ "sourceUpdatedAt": "2026-05-31T04:37:54.058Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:8c4e39a2-745c-4bc5-a2da-a65cf91f8214",
+ "source": "dulo",
+ "sourceChannelId": "8c4e39a2-745c-4bc5-a2da-a65cf91f8214",
+ "name": "Chicago Sports Network HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://cdn.iptvboss.pro/logos/USA/ChicagoSportsNetwork.us.png",
+ "streamEntryUrl": "https://bridge.dulo.tv/memfs/a45a9917-b8e0-4473-bb0e-1ec2377be9d7.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-05-29T23:43:02.847Z",
+ "sourceUpdatedAt": "2026-05-29T23:43:42.123Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ },
+ {
+ "_id": "dulo:44be3457-f36e-4cac-a696-c536cc333b0f",
+ "source": "dulo",
+ "sourceChannelId": "44be3457-f36e-4cac-a696-c536cc333b0f",
+ "name": "Golf Channel HD | USA",
+ "category": "sports",
+ "groupKey": "sports",
+ "groupLabel": "sports",
+ "logoUrl": "https://cdn.iptvboss.pro/logos/USA/GolfChannel.us.png",
+ "streamEntryUrl": "https://gotcha.dulo.tv/memfs/f10f14c7-c851-463c-839c-b86c643d33e0.m3u8",
+ "isPlayable": true,
+ "sourceCreatedAt": "2026-06-09T15:50:56.045Z",
+ "sourceUpdatedAt": "2026-06-09T15:50:56.045Z",
+ "ingestedAt": "2026-06-10T12:27:12.878Z"
+ }
+]
diff --git a/server/src/config.ts b/server/src/config.ts
new file mode 100644
index 00000000..69cfff8f
--- /dev/null
+++ b/server/src/config.ts
@@ -0,0 +1,55 @@
+import { readFileSync, existsSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+export interface AppConfig {
+ mongoUri: string;
+ port: number;
+ logLevel: string;
+}
+
+const VALID_SCHEMES = /^mongodb(\+srv)?:\/\//;
+
+function resolveConfigPath(): string {
+ const envPath = process.env.TVAPP2_CONFIG;
+ if (envPath) {
+ if (!existsSync(envPath)) {
+ throw new Error(`TVAPP2_CONFIG points to "${envPath}" but the file does not exist.`);
+ }
+ return envPath;
+ }
+ const localPath = resolve(process.cwd(), 'config.local.json');
+ if (existsSync(localPath)) return localPath;
+
+ throw new Error(
+ 'No config file found. Set TVAPP2_CONFIG to the mounted config path, ' +
+ 'or create ./config.local.json for development.',
+ );
+}
+
+export function loadConfig(): AppConfig {
+ const path = resolveConfigPath();
+ const raw = readFileSync(path, 'utf8');
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch (err) {
+ throw new Error(`Config file at "${path}" is not valid JSON: ${(err as Error).message}`);
+ }
+
+ if (!parsed || typeof parsed !== 'object') {
+ throw new Error(`Config file at "${path}" must be a JSON object.`);
+ }
+ const obj = parsed as Record;
+
+ const mongoUri = obj.mongoUri;
+ if (typeof mongoUri !== 'string' || !VALID_SCHEMES.test(mongoUri)) {
+ throw new Error(
+ `Config "mongoUri" must be a string starting with mongodb:// or mongodb+srv:// (got ${JSON.stringify(mongoUri)}).`,
+ );
+ }
+
+ const port = typeof obj.port === 'number' ? obj.port : 3000;
+ const logLevel = typeof obj.logLevel === 'string' ? obj.logLevel : 'info';
+
+ return { mongoUri, port, logLevel };
+}
diff --git a/server/src/db.ts b/server/src/db.ts
new file mode 100644
index 00000000..f79a4fda
--- /dev/null
+++ b/server/src/db.ts
@@ -0,0 +1,27 @@
+import mongoose from 'mongoose';
+
+export async function connect(uri: string): Promise {
+ mongoose.connection.on('error', (err) => {
+ console.error('[mongo] connection error:', err.message);
+ });
+ mongoose.connection.on('disconnected', () => {
+ console.warn('[mongo] disconnected');
+ });
+ mongoose.connection.on('reconnected', () => {
+ console.info('[mongo] reconnected');
+ });
+
+ await mongoose.connect(uri, {
+ serverSelectionTimeoutMS: 5000,
+ maxPoolSize: 10,
+ });
+ console.info('[mongo] connected');
+}
+
+export async function disconnect(): Promise {
+ await mongoose.disconnect();
+}
+
+export function isConnected(): boolean {
+ return mongoose.connection.readyState === 1;
+}
diff --git a/server/src/index.ts b/server/src/index.ts
new file mode 100644
index 00000000..8d5cb218
--- /dev/null
+++ b/server/src/index.ts
@@ -0,0 +1,86 @@
+import { existsSync } from 'node:fs';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import express from 'express';
+import { loadConfig } from './config.js';
+import { connect, disconnect } from './db.js';
+import { healthRouter } from './routes/health.js';
+import { playlistsRouter } from './routes/playlists.js';
+import { epgSourcesRouter } from './routes/epgSources.js';
+import { channelsRouter } from './routes/channels.js';
+import { activeStreamsRouter } from './routes/activeStreams.js';
+import { customPlaylistsRouter } from './routes/customPlaylists.js';
+import { programsRouter } from './routes/programs.js';
+import { activityRouter } from './routes/activity.js';
+import { streamSessionsRouter } from './routes/streamSessions.js';
+import { sourcesRouter } from './routes/sources.js';
+import { bootInitSources } from './sources/seed.js';
+
+async function main() {
+ const config = loadConfig();
+
+ try {
+ await connect(config.mongoUri);
+ } catch (err) {
+ console.error('[startup] failed to connect to mongo:', (err as Error).message);
+ process.exit(1);
+ }
+
+ // Ingest the established (Default) source playlists: guarantee each from its committed bundle
+ // (idempotent), then kick a non-blocking live sync. Runs in both Docker variants via this single
+ // boot path. A failure here must not prevent the API from serving.
+ try {
+ await bootInitSources();
+ } catch (err) {
+ console.error('[startup] source init error (continuing):', (err as Error).message);
+ }
+
+ const app = express();
+ app.use(express.json());
+
+ app.use('/api/health', healthRouter);
+ app.use('/api/playlists', playlistsRouter);
+ app.use('/api/epg-sources', epgSourcesRouter);
+ app.use('/api/channels', channelsRouter);
+ app.use('/api/active-streams', activeStreamsRouter);
+ app.use('/api/custom-playlists', customPlaylistsRouter);
+ app.use('/api/epg-programs', programsRouter);
+ app.use('/api/activity', activityRouter);
+ app.use('/api/stream-sessions', streamSessionsRouter);
+ // Generic source API (manifest, stream proxy, status, sync/reset) — mounted at root since its
+ // paths span /api/sources and /api/v1.
+ app.use(sourcesRouter);
+
+ const here = dirname(fileURLToPath(import.meta.url));
+ const publicDir = resolve(here, '..', 'public');
+ if (existsSync(publicDir)) {
+ app.use(express.static(publicDir));
+ app.get(/^\/(?!api\/).*/, (_req, res) => {
+ res.sendFile(resolve(publicDir, 'index.html'));
+ });
+ console.info(`[http] serving SPA from ${publicDir}`);
+ }
+
+ app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
+ console.error('[api] error:', err.message);
+ res.status(500).json({ error: 'internal_error' });
+ });
+
+ const server = app.listen(config.port, () => {
+ console.info(`[api] listening on :${config.port}`);
+ });
+
+ const shutdown = async (signal: string) => {
+ console.info(`[shutdown] received ${signal}`);
+ server.close();
+ await disconnect();
+ process.exit(0);
+ };
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
+ process.on('SIGINT', () => void shutdown('SIGINT'));
+}
+
+main().catch((err) => {
+ console.error('[startup] fatal:', err);
+ process.exit(1);
+});
diff --git a/server/src/models/ActiveStream.ts b/server/src/models/ActiveStream.ts
new file mode 100644
index 00000000..693d184b
--- /dev/null
+++ b/server/src/models/ActiveStream.ts
@@ -0,0 +1,29 @@
+import { Schema, model } from 'mongoose';
+
+const ActiveStreamSchema = new Schema(
+ {
+ id: { type: String, required: true, unique: true, index: true },
+ channelId: { type: String, required: true, index: true },
+ status: { type: String, required: true },
+ uptime: { type: String, required: true },
+ uptimeMin: { type: Number, required: true },
+ viewers: { type: Number, required: true },
+ peakViewers: { type: Number, required: true },
+ bitrate: { type: Number, required: true },
+ targetBitrate: { type: Number, required: true },
+ codec: { type: String, required: true },
+ audio: { type: String, required: true },
+ container: { type: String, required: true },
+ resolution: { type: String, required: true },
+ fps: { type: Number, required: true },
+ sourceUrl: { type: String, required: true },
+ sourceHost: { type: String, required: true },
+ droppedFrames: { type: Number, required: true },
+ droppedRatio: { type: Number, required: true },
+ latency: { type: Number, required: true },
+ bandwidth: { type: Number, required: true },
+ },
+ { versionKey: false },
+);
+
+export const ActiveStream = model('ActiveStream', ActiveStreamSchema);
diff --git a/server/src/models/Activity.ts b/server/src/models/Activity.ts
new file mode 100644
index 00000000..832503d7
--- /dev/null
+++ b/server/src/models/Activity.ts
@@ -0,0 +1,13 @@
+import { Schema, model } from 'mongoose';
+
+const ActivitySchema = new Schema(
+ {
+ when: { type: String, required: true },
+ icon: { type: String, required: true },
+ html: { type: String, required: true },
+ order: { type: Number, required: true, index: true },
+ },
+ { versionKey: false },
+);
+
+export const Activity = model('Activity', ActivitySchema);
diff --git a/server/src/models/Channel.ts b/server/src/models/Channel.ts
new file mode 100644
index 00000000..31643597
--- /dev/null
+++ b/server/src/models/Channel.ts
@@ -0,0 +1,22 @@
+import { Schema, model } from 'mongoose';
+
+const ChannelSchema = new Schema(
+ {
+ id: { type: String, required: true, unique: true, index: true },
+ tvg_name: { type: String, required: true },
+ group: { type: String, required: true },
+ channel: { type: Number, required: true },
+ tvg_id: { type: String, default: null },
+ state: { type: String, enum: ['active', 'disabled'], required: true },
+ epg: { type: String, enum: ['matched', 'unmatched'], required: true },
+ source: { type: String, required: true },
+ url: { type: String, required: true, unique: true },
+ status: { type: String, required: true },
+ res: { type: String, required: true },
+ logoColor: { type: String, required: true },
+ initials: { type: String, required: true },
+ },
+ { versionKey: false },
+);
+
+export const Channel = model('Channel', ChannelSchema);
diff --git a/server/src/models/CustomPlaylist.ts b/server/src/models/CustomPlaylist.ts
new file mode 100644
index 00000000..c969bf43
--- /dev/null
+++ b/server/src/models/CustomPlaylist.ts
@@ -0,0 +1,14 @@
+import { Schema, model } from 'mongoose';
+
+const CustomPlaylistSchema = new Schema(
+ {
+ id: { type: String, required: true, unique: true, index: true },
+ name: { type: String, required: true },
+ slug: { type: String, required: true, index: true },
+ channels: { type: Number, required: true },
+ updated: { type: String, required: true },
+ },
+ { versionKey: false },
+);
+
+export const CustomPlaylist = model('CustomPlaylist', CustomPlaylistSchema);
diff --git a/server/src/models/EpgSource.ts b/server/src/models/EpgSource.ts
new file mode 100644
index 00000000..8422e131
--- /dev/null
+++ b/server/src/models/EpgSource.ts
@@ -0,0 +1,19 @@
+import { Schema, model } from 'mongoose';
+
+const EpgSourceSchema = new Schema(
+ {
+ id: { type: String, required: true, unique: true, index: true },
+ name: { type: String, required: true },
+ url: { type: String, required: true },
+ channels: { type: Number, required: true },
+ programs: { type: Number, required: true },
+ lastSync: { type: String, required: true },
+ status: { type: String, required: true },
+ auto: { type: Boolean, required: true },
+ interval: { type: String, required: true },
+ builtin: { type: Boolean },
+ },
+ { versionKey: false },
+);
+
+export const EpgSource = model('EpgSource', EpgSourceSchema);
diff --git a/server/src/models/Playlist.ts b/server/src/models/Playlist.ts
new file mode 100644
index 00000000..1ea812e0
--- /dev/null
+++ b/server/src/models/Playlist.ts
@@ -0,0 +1,22 @@
+import { Schema, model } from 'mongoose';
+
+const PlaylistSchema = new Schema(
+ {
+ id: { type: String, required: true, unique: true, index: true },
+ name: { type: String, required: true },
+ url: { type: String, required: true },
+ groups: { type: Number, required: true },
+ lastSync: { type: String, required: true },
+ status: { type: String, required: true },
+ auto: { type: Boolean, required: true },
+ interval: { type: String, required: true },
+ builtin: { type: Boolean },
+ // Set for the established (Default) source playlists (dulo/common/dlhd). When present, the
+ // playlist's channels live in the SourceChannel collection (queried by this `source`) instead
+ // of the legacy PlaylistChannel join. Unset for legacy/mock playlists.
+ source: { type: String, default: null, index: true },
+ },
+ { versionKey: false },
+);
+
+export const Playlist = model('Playlist', PlaylistSchema);
diff --git a/server/src/models/PlaylistChannel.ts b/server/src/models/PlaylistChannel.ts
new file mode 100644
index 00000000..27f4f54a
--- /dev/null
+++ b/server/src/models/PlaylistChannel.ts
@@ -0,0 +1,15 @@
+import { Schema, model } from 'mongoose';
+
+const PlaylistChannelSchema = new Schema(
+ {
+ playlistId: { type: String, required: true, index: true },
+ channelId: { type: String, required: true, index: true },
+ order: { type: Number, required: true },
+ },
+ { versionKey: false },
+);
+
+PlaylistChannelSchema.index({ playlistId: 1, channelId: 1 }, { unique: true });
+PlaylistChannelSchema.index({ playlistId: 1, order: 1 });
+
+export const PlaylistChannel = model('PlaylistChannel', PlaylistChannelSchema);
diff --git a/server/src/models/Program.ts b/server/src/models/Program.ts
new file mode 100644
index 00000000..51822ad0
--- /dev/null
+++ b/server/src/models/Program.ts
@@ -0,0 +1,16 @@
+import { Schema, model } from 'mongoose';
+
+const ProgramSchema = new Schema(
+ {
+ channelId: { type: String, required: true, index: true },
+ start: { type: Number, required: true },
+ end: { type: Number, required: true },
+ title: { type: String, required: true },
+ cat: { type: String, required: true },
+ },
+ { versionKey: false },
+);
+
+ProgramSchema.index({ channelId: 1, start: 1 });
+
+export const Program = model('Program', ProgramSchema);
diff --git a/server/src/models/SourceChannel.ts b/server/src/models/SourceChannel.ts
new file mode 100644
index 00000000..53dd5372
--- /dev/null
+++ b/server/src/models/SourceChannel.ts
@@ -0,0 +1,49 @@
+import { Schema, model } from 'mongoose';
+
+// SourceChannel — the canonical normalized channel document, adopted verbatim from d-combine's
+// shared `channels` collection (see ../d-combine/docs/combine-architecture.md). One doc per channel
+// across every (Default) source playlist, with a deterministic string `_id` (":") so
+// re-imports/syncs upsert idempotently. The Vue UI never reads this shape directly — the sources
+// router projects it through translate.ts (toUiChannel) into the legacy Channel shape on read.
+
+export interface SourceChannelDoc {
+ _id: string; // ":" — deterministic, collision-proof
+ source: string; // discriminator / UI heading / proxy prefix
+ sourceChannelId: string; // original upstream id as string (UUID, "51", SHA1…)
+ name: string;
+ category: string | null; // dulo: semantic; dlhd: null
+ groupKey: string; // UI bucket key (dulo: category; dlhd: first letter)
+ groupLabel: string;
+ logoUrl: string | null; // dulo/common: logo; dlhd: null
+ streamEntryUrl: string; // URL handed to the proxy (master .m3u8 or dlhd watch.php entry)
+ isPlayable: boolean; // false for malformed source URLs
+ sourceCreatedAt: string | null; // dulo timestamps; dlhd/common null
+ sourceUpdatedAt: string | null;
+ ingestedAt: string; // when buildSource/seed wrote it
+}
+
+const SourceChannelSchema = new Schema(
+ {
+ _id: { type: String, required: true },
+ source: { type: String, required: true },
+ sourceChannelId: { type: String, required: true },
+ name: { type: String, required: true },
+ category: { type: String, default: null },
+ groupKey: { type: String, required: true },
+ groupLabel: { type: String, required: true },
+ logoUrl: { type: String, default: null },
+ streamEntryUrl: { type: String, required: true },
+ isPlayable: { type: Boolean, required: true },
+ sourceCreatedAt: { type: String, default: null },
+ sourceUpdatedAt: { type: String, default: null },
+ ingestedAt: { type: String, required: true },
+ },
+ { versionKey: false },
+);
+
+// Covers the per-source grouped/ordered listing query (source → groupKey → name).
+SourceChannelSchema.index({ source: 1, groupKey: 1, name: 1 });
+// Dead-channel / playable filtering per source.
+SourceChannelSchema.index({ source: 1, isPlayable: 1 });
+
+export const SourceChannel = model('SourceChannel', SourceChannelSchema);
diff --git a/server/src/models/StreamSession.ts b/server/src/models/StreamSession.ts
new file mode 100644
index 00000000..908d2cb4
--- /dev/null
+++ b/server/src/models/StreamSession.ts
@@ -0,0 +1,15 @@
+import { Schema, model } from 'mongoose';
+
+const StreamSessionSchema = new Schema(
+ {
+ ip: { type: String, required: true },
+ region: { type: String, required: true },
+ client: { type: String, required: true },
+ joined: { type: String, required: true },
+ bitrate: { type: String, required: true },
+ order: { type: Number, required: true, index: true },
+ },
+ { versionKey: false },
+);
+
+export const StreamSession = model('StreamSession', StreamSessionSchema);
diff --git a/server/src/routes/activeStreams.ts b/server/src/routes/activeStreams.ts
new file mode 100644
index 00000000..dccd9ddd
--- /dev/null
+++ b/server/src/routes/activeStreams.ts
@@ -0,0 +1,13 @@
+import { Router } from 'express';
+import { ActiveStream } from '../models/ActiveStream.js';
+
+export const activeStreamsRouter = Router();
+
+activeStreamsRouter.get('/', async (_req, res, next) => {
+ try {
+ const docs = await ActiveStream.find({}, { _id: 0 }).lean();
+ res.json(docs);
+ } catch (err) {
+ next(err);
+ }
+});
diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts
new file mode 100644
index 00000000..1adadb21
--- /dev/null
+++ b/server/src/routes/activity.ts
@@ -0,0 +1,13 @@
+import { Router } from 'express';
+import { Activity } from '../models/Activity.js';
+
+export const activityRouter = Router();
+
+activityRouter.get('/', async (_req, res, next) => {
+ try {
+ const docs = await Activity.find({}, { _id: 0, order: 0 }).sort({ order: 1 }).lean();
+ res.json(docs);
+ } catch (err) {
+ next(err);
+ }
+});
diff --git a/server/src/routes/channels.ts b/server/src/routes/channels.ts
new file mode 100644
index 00000000..e5b8fc34
--- /dev/null
+++ b/server/src/routes/channels.ts
@@ -0,0 +1,22 @@
+import { Router } from 'express';
+import { Channel } from '../models/Channel.js';
+import { SourceChannel } from '../models/SourceChannel.js';
+
+export const channelsRouter = Router();
+
+// GET /api/channels → legacy mock channels (drives the existing dashboard bootstrap)
+// GET /api/channels?source= → canonical normalized SourceChannel docs for a (Default) source
+// playlist (the d-combine "path forward" contract, served over Mongo)
+channelsRouter.get('/', async (req, res, next) => {
+ try {
+ const source = typeof req.query.source === 'string' ? req.query.source : null;
+ if (source) {
+ const docs = await SourceChannel.find({ source }).sort({ groupKey: 1, name: 1 }).lean();
+ return res.json(docs);
+ }
+ const docs = await Channel.find({}, { _id: 0 }).lean();
+ res.json(docs);
+ } catch (err) {
+ next(err);
+ }
+});
diff --git a/server/src/routes/customPlaylists.ts b/server/src/routes/customPlaylists.ts
new file mode 100644
index 00000000..12d39418
--- /dev/null
+++ b/server/src/routes/customPlaylists.ts
@@ -0,0 +1,13 @@
+import { Router } from 'express';
+import { CustomPlaylist } from '../models/CustomPlaylist.js';
+
+export const customPlaylistsRouter = Router();
+
+customPlaylistsRouter.get('/', async (_req, res, next) => {
+ try {
+ const docs = await CustomPlaylist.find({}, { _id: 0 }).lean();
+ res.json(docs);
+ } catch (err) {
+ next(err);
+ }
+});
diff --git a/server/src/routes/epgSources.ts b/server/src/routes/epgSources.ts
new file mode 100644
index 00000000..c9c9faae
--- /dev/null
+++ b/server/src/routes/epgSources.ts
@@ -0,0 +1,13 @@
+import { Router } from 'express';
+import { EpgSource } from '../models/EpgSource.js';
+
+export const epgSourcesRouter = Router();
+
+epgSourcesRouter.get('/', async (_req, res, next) => {
+ try {
+ const docs = await EpgSource.find({}, { _id: 0 }).lean();
+ res.json(docs);
+ } catch (err) {
+ next(err);
+ }
+});
diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts
new file mode 100644
index 00000000..2aa3af1e
--- /dev/null
+++ b/server/src/routes/health.ts
@@ -0,0 +1,8 @@
+import { Router } from 'express';
+import { isConnected } from '../db.js';
+
+export const healthRouter = Router();
+
+healthRouter.get('/', (_req, res) => {
+ res.json({ ok: true, mongo: isConnected() ? 'connected' : 'disconnected' });
+});
diff --git a/server/src/routes/playlists.ts b/server/src/routes/playlists.ts
new file mode 100644
index 00000000..19cf0965
--- /dev/null
+++ b/server/src/routes/playlists.ts
@@ -0,0 +1,113 @@
+import { Router } from 'express';
+import { Playlist } from '../models/Playlist.js';
+import { PlaylistChannel } from '../models/PlaylistChannel.js';
+import { Channel } from '../models/Channel.js';
+import { SourceChannel, type SourceChannelDoc } from '../models/SourceChannel.js';
+import { toUiChannel } from '../sources/translate.js';
+
+export const playlistsRouter = Router();
+
+// Channel count comes from SourceChannel for (Default) source playlists, else the legacy join table.
+async function channelCountFor(doc: { id: string; source?: string | null }): Promise {
+ if (doc.source) return SourceChannel.countDocuments({ source: doc.source });
+ return PlaylistChannel.countDocuments({ playlistId: doc.id });
+}
+
+playlistsRouter.get('/', async (_req, res, next) => {
+ try {
+ const docs = await Playlist.find({}, { _id: 0 }).lean();
+ const [legacyCounts, sourceCounts] = await Promise.all([
+ PlaylistChannel.aggregate<{ _id: string; count: number }>([
+ { $group: { _id: '$playlistId', count: { $sum: 1 } } },
+ ]),
+ SourceChannel.aggregate<{ _id: string; count: number }>([
+ { $group: { _id: '$source', count: { $sum: 1 } } },
+ ]),
+ ]);
+ const legacyById = new Map(legacyCounts.map((c) => [c._id, c.count]));
+ const sourceBySource = new Map(sourceCounts.map((c) => [c._id, c.count]));
+ res.json(
+ docs.map((d) => ({
+ ...d,
+ channels: d.source ? sourceBySource.get(d.source) ?? 0 : legacyById.get(d.id) ?? 0,
+ })),
+ );
+ } catch (err) {
+ next(err);
+ }
+});
+
+playlistsRouter.get('/:id', async (req, res, next) => {
+ try {
+ const doc = await Playlist.findOne({ id: req.params.id }, { _id: 0 }).lean();
+ if (!doc) return res.status(404).json({ error: 'not_found' });
+ res.json({ ...doc, channels: await channelCountFor(doc) });
+ } catch (err) {
+ next(err);
+ }
+});
+
+// List channels in a playlist. (Default) source playlists project the canonical SourceChannel docs
+// through the translation layer (UI shape, nulls for unmapped fields); legacy playlists use the join.
+playlistsRouter.get('/:id/channels', async (req, res, next) => {
+ try {
+ const playlist = await Playlist.findOne({ id: req.params.id }).lean();
+ if (!playlist) return res.status(404).json({ error: 'not_found' });
+
+ if (playlist.source) {
+ const docs = await SourceChannel.find({ source: playlist.source })
+ .sort({ groupKey: 1, name: 1 })
+ .lean();
+ return res.json(docs.map((d, order) => ({ ...toUiChannel(d), order })));
+ }
+
+ const memberships = await PlaylistChannel.find({ playlistId: req.params.id }, { _id: 0 })
+ .sort({ order: 1 })
+ .lean();
+ const channelIds = memberships.map((m) => m.channelId);
+ const channels = await Channel.find({ id: { $in: channelIds } }, { _id: 0 }).lean();
+ const byId = new Map(channels.map((c) => [c.id, c]));
+ res.json(
+ memberships
+ .map((m) => {
+ const ch = byId.get(m.channelId);
+ return ch ? { ...ch, order: m.order } : null;
+ })
+ .filter(Boolean),
+ );
+ } catch (err) {
+ next(err);
+ }
+});
+
+// Add a channel to a (legacy) playlist (idempotent on the unique pair).
+playlistsRouter.post('/:id/channels', async (req, res, next) => {
+ try {
+ const { channelId, order } = req.body ?? {};
+ if (typeof channelId !== 'string' || typeof order !== 'number') {
+ return res.status(400).json({ error: 'channelId (string) and order (number) required' });
+ }
+ const doc = await PlaylistChannel.findOneAndUpdate(
+ { playlistId: req.params.id, channelId },
+ { $set: { order } },
+ { upsert: true, new: true, projection: { _id: 0 } },
+ ).lean();
+ res.status(201).json(doc);
+ } catch (err) {
+ next(err);
+ }
+});
+
+// Remove a channel from a (legacy) playlist.
+playlistsRouter.delete('/:id/channels/:channelId', async (req, res, next) => {
+ try {
+ const result = await PlaylistChannel.deleteOne({
+ playlistId: req.params.id,
+ channelId: req.params.channelId,
+ });
+ if (result.deletedCount === 0) return res.status(404).json({ error: 'not_found' });
+ res.status(204).end();
+ } catch (err) {
+ next(err);
+ }
+});
diff --git a/server/src/routes/programs.ts b/server/src/routes/programs.ts
new file mode 100644
index 00000000..af3549d7
--- /dev/null
+++ b/server/src/routes/programs.ts
@@ -0,0 +1,19 @@
+import { Router } from 'express';
+import { Program } from '../models/Program.js';
+
+export const programsRouter = Router();
+
+// All programs, grouped by channelId — matches the EPG_PROGRAMS shape the SPA expects.
+programsRouter.get('/', async (_req, res, next) => {
+ try {
+ const docs = await Program.find({}, { _id: 0 }).sort({ channelId: 1, start: 1 }).lean();
+ const grouped: Record> = {};
+ for (const d of docs) {
+ const list = grouped[d.channelId] ?? (grouped[d.channelId] = []);
+ list.push({ start: d.start, end: d.end, title: d.title, cat: d.cat });
+ }
+ res.json(grouped);
+ } catch (err) {
+ next(err);
+ }
+});
diff --git a/server/src/routes/sources.ts b/server/src/routes/sources.ts
new file mode 100644
index 00000000..1ebee75b
--- /dev/null
+++ b/server/src/routes/sources.ts
@@ -0,0 +1,91 @@
+// Generic source RestAPI, ported from ../d-combine/server.mjs into TVApp2's Express stack. One router
+// serves every source by iterating the registry — adding a source needs zero route changes.
+//
+// GET /api/sources manifest (drives the SPA; one entry per registered source)
+// GET /api/sources/:id/status runtime provenance (dlhd: live mirror; null otherwise)
+// GET /api/sources/:id/metrics per-source proxy counters
+// POST /api/sources/:id/sync live refresh → upsert channels + Playlist sync metadata
+// POST /api/sources/:id/reset restore the committed bundle baseline
+// GET /api/v1/:source/* single stream proxy; the :source segment binds that source's
+// resolve+proxy behavior (createProxyHandler per adapter)
+//
+// Mounted at the app root (app.use(sourcesRouter)) because its paths span /api/sources, /api/v1, …
+
+import { Router, type RequestHandler } from 'express';
+import { SOURCES, getSource } from '../sources/registry.js';
+import { createProxyHandler } from '../sources/core/proxyHandler.js';
+import { createMetrics, snapshotOne, type Metrics } from '../sources/core/metrics.js';
+import { syncLive, resetFromBundle } from '../sources/seed.js';
+
+export const sourcesRouter = Router();
+
+// Build one proxy handler (+ metrics bag) per source once, then dispatch by the :source segment.
+const metricsById = new Map();
+const proxyHandlers = new Map();
+for (const adapter of SOURCES) {
+ const m = createMetrics();
+ metricsById.set(adapter.id, m);
+ proxyHandlers.set(adapter.id, createProxyHandler(adapter, m) as RequestHandler);
+}
+
+// ── Manifest ────────────────────────────────────────────────────────────────
+sourcesRouter.get('/api/sources', (_req, res) => {
+ res.json(
+ SOURCES.map((s) => ({
+ id: s.id,
+ label: s.label,
+ grouping: s.grouping,
+ sourceUrl: `/api/channels?source=${s.id}`, // normalized catalog over Mongo
+ proxyPrefix: `/api/v1/${s.id}/`,
+ statusUrl: s.status ? `/api/sources/${s.id}/status` : null,
+ })),
+ );
+});
+
+// ── Per-source runtime status (dlhd mirror provenance; null for sources without one) ──
+sourcesRouter.get('/api/sources/:id/status', async (req, res, next) => {
+ try {
+ const adapter = getSource(req.params.id);
+ if (!adapter) return res.status(404).json({ error: `Unknown source: ${req.params.id}` });
+ const status = adapter.status ? await adapter.status() : null;
+ res.json(status ?? null);
+ } catch (err) {
+ next(err);
+ }
+});
+
+// ── Per-source proxy metrics ──────────────────────────────────────────────────
+sourcesRouter.get('/api/sources/:id/metrics', (req, res) => {
+ const m = metricsById.get(req.params.id);
+ if (!m) return res.status(404).json({ error: `Unknown source: ${req.params.id}` });
+ res.json(snapshotOne(m));
+});
+
+// ── Live sync (refresh channels + Playlist sync metadata from upstream) ───────
+sourcesRouter.post('/api/sources/:id/sync', async (req, res, next) => {
+ try {
+ if (!getSource(req.params.id)) return res.status(404).json({ error: `Unknown source: ${req.params.id}` });
+ res.json(await syncLive(req.params.id));
+ } catch (err) {
+ next(err);
+ }
+});
+
+// ── Reset to the committed bundle baseline ────────────────────────────────────
+sourcesRouter.post('/api/sources/:id/reset', async (req, res, next) => {
+ try {
+ if (!getSource(req.params.id)) return res.status(404).json({ error: `Unknown source: ${req.params.id}` });
+ res.json(await resetFromBundle(req.params.id));
+ } catch (err) {
+ next(err);
+ }
+});
+
+// ── Single stream proxy API ───────────────────────────────────────────────────
+sourcesRouter.get('/api/v1/:source/*', (req, res) => {
+ const handler = proxyHandlers.get(req.params.source);
+ if (!handler) {
+ return res.status(404).type('text/plain').send(`Unknown source: ${req.params.source}`);
+ }
+ return handler(req, res, () => undefined);
+});
diff --git a/server/src/routes/streamSessions.ts b/server/src/routes/streamSessions.ts
new file mode 100644
index 00000000..c00986d3
--- /dev/null
+++ b/server/src/routes/streamSessions.ts
@@ -0,0 +1,13 @@
+import { Router } from 'express';
+import { StreamSession } from '../models/StreamSession.js';
+
+export const streamSessionsRouter = Router();
+
+streamSessionsRouter.get('/', async (_req, res, next) => {
+ try {
+ const docs = await StreamSession.find({}, { _id: 0, order: 0 }).sort({ order: 1 }).lean();
+ res.json(docs);
+ } catch (err) {
+ next(err);
+ }
+});
diff --git a/server/src/seed.ts b/server/src/seed.ts
new file mode 100644
index 00000000..458f6ab1
--- /dev/null
+++ b/server/src/seed.ts
@@ -0,0 +1,176 @@
+// Seed script — populates every collection from inlined mock data.
+// Idempotent: drops and refills each collection.
+//
+// cd server && npm run seed
+//
+// Uses the same config resolution as the API (TVAPP2_CONFIG or ./config.local.json).
+
+import { loadConfig } from './config.js';
+import { connect, disconnect } from './db.js';
+import { Playlist } from './models/Playlist.js';
+import { Channel } from './models/Channel.js';
+import { PlaylistChannel } from './models/PlaylistChannel.js';
+import { EpgSource } from './models/EpgSource.js';
+import { CustomPlaylist } from './models/CustomPlaylist.js';
+import { ActiveStream } from './models/ActiveStream.js';
+import { Program } from './models/Program.js';
+import { Activity } from './models/Activity.js';
+import { StreamSession } from './models/StreamSession.js';
+
+const PLAYLISTS = [
+ { id: 'pl-default', name: 'Default', url: 'bundled://tvapp2/default.m3u', groups: 6, lastSync: 'Ships with TVApp2', status: 'good', auto: true, interval: 'Auto-updated', builtin: true },
+ { id: 'pl-iptv-pro', name: 'IPTV-Pro Main', url: 'https://iptv-pro.example.com/playlist.m3u8', groups: 8, lastSync: '2 minutes ago', status: 'good', auto: true, interval: 'Every 6 hours' },
+ { id: 'pl-free-uk', name: 'Free UK Bouquet', url: 'https://iptv-org.github.io/iptv/countries/uk.m3u', groups: 4, lastSync: '1 hour ago', status: 'good', auto: true, interval: 'Daily' },
+ { id: 'pl-archive', name: 'Archive (legacy)', url: 'file:///playlists/archive-2023.m3u', groups: 3, lastSync: '3 days ago', status: 'warn', auto: false, interval: 'Manual' },
+];
+
+const EPG_SOURCES = [
+ { id: 'epg-default', name: 'Default', url: 'bundled://tvapp2/default.xml.gz', channels: 86, programs: 5240, lastSync: 'Ships with TVApp2', status: 'good', auto: true, interval: 'Auto-updated', builtin: true },
+ { id: 'epg-xmltv-uk', name: 'XMLTV UK Guide', url: 'https://epg.example.com/uk.xml.gz', channels: 124, programs: 8420, lastSync: '12 minutes ago', status: 'good', auto: true, interval: 'Every 12 hours' },
+ { id: 'epg-iptv-org', name: 'iptv-org world EPG', url: 'https://iptv-org.github.io/epg/guides/uk/openepg.xml', channels: 412, programs: 24180, lastSync: '2 hours ago', status: 'good', auto: true, interval: 'Daily' },
+];
+
+const CHANNEL_SEEDS = [
+ { tvg_name: 'BBC One HD', group: 'Entertainment', channel: 101, tvg_id: 'bbc.one.uk', state: 'active', epg: 'matched', status: 'good', res: '1080p' },
+ { tvg_name: 'BBC Two HD', group: 'Entertainment', channel: 102, tvg_id: 'bbc.two.uk', state: 'active', epg: 'matched', status: 'good', res: '1080p' },
+ { tvg_name: 'BBC News', group: 'News', channel: 231, tvg_id: 'bbc.news.uk', state: 'active', epg: 'matched', status: 'good', res: '720p' },
+ { tvg_name: 'Sky Sports Main', group: 'Sport', channel: 401, tvg_id: 'sky.sports.main.uk', state: 'active', epg: 'matched', status: 'good', res: '1080p' },
+ { tvg_name: 'Sky Sports F1', group: 'Sport', channel: 406, tvg_id: 'sky.sports.f1.uk', state: 'active', epg: 'matched', status: 'good', res: '1080p' },
+ { tvg_name: 'ITV1 HD', group: 'Entertainment', channel: 103, tvg_id: 'itv1.uk', state: 'active', epg: 'matched', status: 'good', res: '1080p' },
+ { tvg_name: 'Channel 4 HD', group: 'Entertainment', channel: 104, tvg_id: 'channel4.uk', state: 'active', epg: 'matched', status: 'warn', res: '1080p' },
+ { tvg_name: 'Film4', group: 'Movies', channel: 315, tvg_id: 'film4.uk', state: 'active', epg: 'matched', status: 'good', res: '720p' },
+ { tvg_name: 'Discovery Channel', group: 'Documentary', channel: 520, tvg_id: 'discovery.uk', state: 'active', epg: 'matched', status: 'good', res: '1080p' },
+ { tvg_name: 'National Geographic', group: 'Documentary', channel: 521, tvg_id: 'natgeo.uk', state: 'active', epg: 'unmatched', status: 'good', res: '1080p' },
+ { tvg_name: 'CNN International', group: 'News', channel: 233, tvg_id: 'cnn.int', state: 'active', epg: 'matched', status: 'good', res: '720p' },
+ { tvg_name: 'Al Jazeera English', group: 'News', channel: 235, tvg_id: 'aljazeera.en', state: 'active', epg: 'matched', status: 'good', res: '720p' },
+ { tvg_name: 'Cartoon Network', group: 'Kids', channel: 601, tvg_id: 'cartoonnet.uk', state: 'active', epg: 'matched', status: 'good', res: '720p' },
+ { tvg_name: 'Nick Jr.', group: 'Kids', channel: 615, tvg_id: null, state: 'disabled', epg: 'unmatched', status: 'warn', res: '720p' },
+ { tvg_name: 'MTV Hits', group: 'Music', channel: 365, tvg_id: 'mtv.hits.uk', state: 'active', epg: 'matched', status: 'good', res: '720p' },
+ { tvg_name: 'Kerrang!', group: 'Music', channel: 369, tvg_id: 'kerrang.uk', state: 'active', epg: 'matched', status: 'good', res: '720p' },
+ { tvg_name: 'Food Network', group: 'Lifestyle', channel: 240, tvg_id: 'foodnet.uk', state: 'active', epg: 'matched', status: 'good', res: '720p' },
+ { tvg_name: 'HGTV', group: 'Lifestyle', channel: 242, tvg_id: null, state: 'disabled', epg: 'unmatched', status: 'bad', res: '720p' },
+ { tvg_name: 'TCM Movies', group: 'Movies', channel: 320, tvg_id: 'tcm.uk', state: 'active', epg: 'matched', status: 'good', res: '1080p' },
+ { tvg_name: 'Eurosport 1', group: 'Sport', channel: 410, tvg_id: 'eurosport1.uk', state: 'active', epg: 'matched', status: 'good', res: '1080p' },
+];
+
+const CHANNELS = CHANNEL_SEEDS.map((c, i) => ({
+ id: `ch-${i}`,
+ ...c,
+ source: 'Default',
+ url: `http://sample.stream.com/channel/1.m3u8?ch=ch-${i}`,
+ logoColor: `oklch(0.5 0.16 ${(i * 47) % 360})`,
+ initials: c.tvg_name.split(/\s+/).slice(0, 2).map((w) => w[0]).join('').toUpperCase(),
+}));
+
+const CUSTOM_PLAYLISTS = [
+ { id: 'cust-sports-night', name: 'Sports Night', slug: 'sports-night', channels: 12, updated: '2 days ago' },
+ { id: 'cust-kids-safe', name: 'Kids Safe', slug: 'kids-safe', channels: 8, updated: 'yesterday' },
+ { id: 'cust-news-rotation', name: 'News Rotation', slug: 'news-rotation', channels: 6, updated: '5 hours ago' },
+ { id: 'cust-living-room', name: 'Living Room Favorites', slug: 'living-room', channels: 18, updated: '1 week ago' },
+];
+
+const ACTIVE_STREAMS = [
+ { id: 'as-1', channelId: 'ch-0', status: 'good', uptime: '4h 12m', uptimeMin: 252, viewers: 142, peakViewers: 168, bitrate: 6.4, targetBitrate: 6.0, codec: 'H.264 High@4.1', audio: 'AAC LC 2.0 · 128k', container: 'HLS / TS', resolution: '1920×1080', fps: 50, sourceUrl: 'http://stream.iptv-pro.example.com/live/bbc-one/index.m3u8', sourceHost: 'edge-fra-04', droppedFrames: 0, droppedRatio: 0.00, latency: 2.1, bandwidth: 912 },
+ { id: 'as-2', channelId: 'ch-3', status: 'good', uptime: '1h 48m', uptimeMin: 108, viewers: 89, peakViewers: 112, bitrate: 8.2, targetBitrate: 8.0, codec: 'H.264 High@4.2', audio: 'AC3 5.1 · 384k', container: 'HLS / fMP4', resolution: '1920×1080', fps: 50, sourceUrl: 'http://stream.iptv-pro.example.com/live/sky-sports-main/index.m3u8', sourceHost: 'edge-lon-02', droppedFrames: 14, droppedRatio: 0.01, latency: 1.8, bandwidth: 730 },
+ { id: 'as-3', channelId: 'ch-2', status: 'good', uptime: '22h 06m', uptimeMin: 1326, viewers: 47, peakViewers: 61, bitrate: 3.1, targetBitrate: 3.0, codec: 'H.264 Main@3.1', audio: 'AAC LC 2.0 · 96k', container: 'HLS / TS', resolution: '1280×720', fps: 25, sourceUrl: 'http://stream.iptv-pro.example.com/live/bbc-news/index.m3u8', sourceHost: 'edge-fra-04', droppedFrames: 2, droppedRatio: 0.00, latency: 2.4, bandwidth: 145 },
+ { id: 'as-4', channelId: 'ch-6', status: 'warn', uptime: '12m', uptimeMin: 12, viewers: 8, peakViewers: 8, bitrate: 4.1, targetBitrate: 5.0, codec: 'H.264 High@4.0', audio: 'AAC LC 2.0 · 128k', container: 'HLS / TS', resolution: '1920×1080', fps: 25, sourceUrl: 'http://stream.iptv-pro.example.com/live/channel4/index.m3u8', sourceHost: 'edge-ams-01', droppedFrames: 184, droppedRatio: 0.47, latency: 4.7, bandwidth: 34 },
+ { id: 'as-5', channelId: 'ch-4', status: 'good', uptime: '3h 41m', uptimeMin: 221, viewers: 31, peakViewers: 44, bitrate: 7.9, targetBitrate: 8.0, codec: 'H.264 High@4.2', audio: 'AC3 5.1 · 384k', container: 'HLS / fMP4', resolution: '1920×1080', fps: 50, sourceUrl: 'http://stream.iptv-pro.example.com/live/sky-sports-f1/index.m3u8', sourceHost: 'edge-lon-02', droppedFrames: 0, droppedRatio: 0.00, latency: 1.9, bandwidth: 245 },
+ { id: 'as-6', channelId: 'ch-8', status: 'good', uptime: '45m', uptimeMin: 45, viewers: 12, peakViewers: 12, bitrate: 5.6, targetBitrate: 6.0, codec: 'H.265 Main@4.0', audio: 'AAC LC 2.0 · 128k', container: 'HLS / fMP4', resolution: '1920×1080', fps: 25, sourceUrl: 'http://stream.iptv-pro.example.com/live/discovery/index.m3u8', sourceHost: 'edge-fra-04', droppedFrames: 1, droppedRatio: 0.00, latency: 2.2, bandwidth: 68 },
+ { id: 'as-7', channelId: 'ch-17', status: 'bad', uptime: '—', uptimeMin: 0, viewers: 0, peakViewers: 4, bitrate: 0, targetBitrate: 5.0, codec: '—', audio: '—', container: 'HLS / TS', resolution: '—', fps: 0, sourceUrl: 'http://stream.iptv-pro.example.com/live/hgtv/index.m3u8', sourceHost: 'edge-ams-01', droppedFrames: 0, droppedRatio: 0, latency: 0, bandwidth: 0 },
+];
+
+const ACTIVITY = [
+ { when: '2m', icon: 'sync', html: 'IPTV-Pro Main synced — 142 channels, no changes' },
+ { when: '12m', icon: 'epg', html: 'XMLTV UK Guide imported — 8,420 programs across 124 channels' },
+ { when: '1h', icon: 'map', html: 'Manual mapping: HGTV → hgtv.uk' },
+ { when: '1h', icon: 'warn', html: 'Free UK Bouquet reports 3 channels offline (HTTP 503)' },
+ { when: '3h', icon: 'edit', html: 'Renamed Discovery → Discovery Channel ' },
+ { when: 'Yest.', icon: 'add', html: 'Playlist IPTV-Pro Main added (142 channels)' },
+];
+
+const STREAM_SESSIONS = [
+ { ip: '82.14.221.47', region: 'GB · London', client: 'VLC / Linux', joined: '2m ago', bitrate: '6.4 Mbps' },
+ { ip: '192.81.45.12', region: 'DE · Frankfurt', client: 'Tivimate / Android TV', joined: '8m ago', bitrate: '6.4 Mbps' },
+ { ip: '104.18.92.5', region: 'NL · Amsterdam', client: 'OTT Navigator / FireTV', joined: '14m ago', bitrate: '3.1 Mbps' },
+ { ip: '176.58.103.9', region: 'GB · Manchester', client: 'Kodi 21', joined: '31m ago', bitrate: '6.4 Mbps' },
+ { ip: '78.143.211.4', region: 'FR · Paris', client: 'IPTV Smarters / iOS', joined: '1h ago', bitrate: '3.1 Mbps' },
+ { ip: '10.0.4.118', region: 'Local · LAN', client: 'ffmpeg / probe', joined: '3h ago', bitrate: '6.4 Mbps' },
+];
+
+const PROGRAM_LIBRARY: [string, string][] = [
+ ['Morning News', 'Live'], ['Breakfast Show', 'Lifestyle'], ['Market Report', 'Business'],
+ ['Sports Roundup', 'Highlights'], ['Drama Hour', 'Series'], ['World Headlines', 'News'],
+ ['Wildlife Special', 'Documentary'], ['Cooking with Anna', 'Lifestyle'],
+ ['Classic Movies', 'Film'], ['Talk of the Day', 'Discussion'], ["Children's Hour", 'Kids'],
+ ['Weather Watch', 'Weather'], ['Live Match', 'Football'], ['Tech Today', 'Technology'],
+ ['Late Show', 'Comedy'], ['Documentary', 'Feature'], ['Music Mix', 'Music'],
+ ['Quiz Night', 'Game show'], ['Reality TV', 'Series'], ['The Daily Brief', 'News'],
+];
+
+function rngFor(seed: number) {
+ let s = seed;
+ return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
+}
+
+function generatePrograms(channelId: string, seedBase: number) {
+ const rng = rngFor(seedBase);
+ const progs: Array<{ channelId: string; start: number; end: number; title: string; cat: string }> = [];
+ let t = 0;
+ while (t < 24) {
+ const dur = [0.5, 1, 1, 1.5, 2][Math.floor(rng() * 5)];
+ const idx = Math.floor(rng() * PROGRAM_LIBRARY.length);
+ progs.push({ channelId, start: t, end: Math.min(24, t + dur), title: PROGRAM_LIBRARY[idx][0], cat: PROGRAM_LIBRARY[idx][1] });
+ t += dur;
+ }
+ return progs;
+}
+
+async function main() {
+ const config = loadConfig();
+ await connect(config.mongoUri);
+
+ await Promise.all([
+ Playlist.deleteMany({}),
+ Channel.deleteMany({}),
+ PlaylistChannel.deleteMany({}),
+ EpgSource.deleteMany({}),
+ CustomPlaylist.deleteMany({}),
+ ActiveStream.deleteMany({}),
+ Program.deleteMany({}),
+ Activity.deleteMany({}),
+ StreamSession.deleteMany({}),
+ ]);
+
+ await Playlist.insertMany(PLAYLISTS);
+ await Channel.insertMany(CHANNELS);
+ await EpgSource.insertMany(EPG_SOURCES);
+ await CustomPlaylist.insertMany(CUSTOM_PLAYLISTS);
+ await ActiveStream.insertMany(ACTIVE_STREAMS);
+
+ // Default playlist holds every channel in order.
+ const defaultPlaylist = PLAYLISTS.find((p) => p.builtin) ?? PLAYLISTS[0];
+ await PlaylistChannel.insertMany(
+ CHANNELS.map((c, order) => ({ playlistId: defaultPlaylist.id, channelId: c.id, order })),
+ );
+
+ // EPG programs for the first 12 channels (matches the original mock).
+ const programs = CHANNELS.slice(0, 12).flatMap((c, i) => generatePrograms(c.id, 100 + i * 7));
+ await Program.insertMany(programs);
+
+ await Activity.insertMany(ACTIVITY.map((a, order) => ({ ...a, order })));
+ await StreamSession.insertMany(STREAM_SESSIONS.map((s, order) => ({ ...s, order })));
+
+ console.info(
+ `[seed] playlists=${PLAYLISTS.length} channels=${CHANNELS.length} ` +
+ `epg-sources=${EPG_SOURCES.length} custom-playlists=${CUSTOM_PLAYLISTS.length} ` +
+ `active-streams=${ACTIVE_STREAMS.length} programs=${programs.length} ` +
+ `activity=${ACTIVITY.length} stream-sessions=${STREAM_SESSIONS.length}`,
+ );
+
+ await disconnect();
+}
+
+main().catch((err) => {
+ console.error('[seed] failed:', err);
+ process.exit(1);
+});
diff --git a/server/src/sources/adapters/dulo.ts b/server/src/sources/adapters/dulo.ts
new file mode 100644
index 00000000..9039aa52
--- /dev/null
+++ b/server/src/sources/adapters/dulo.ts
@@ -0,0 +1,118 @@
+// dulo.tv source adapter (Phase 1). Ported from ../d-combine/sources/dulo/adapter.mjs.
+//
+// dulo.tv exposes a JSON catalog API and each channel's `source_url` IS a token-free HLS master
+// playlist. The memfs hosts gate playback behind an Origin allowlist, so the proxy injects
+// `Origin: https://dulo.tv` on every hop. No server-side resolve is needed.
+
+import { readFileSync } from 'node:fs';
+import { snapshotFile } from '../paths.js';
+import type { SourceAdapter } from '../types.js';
+import type { SourceChannelDoc } from '../../models/SourceChannel.js';
+
+const SNAPSHOT = snapshotFile('dulo');
+const DULO_ORIGIN = 'https://dulo.tv';
+const DULO_API = process.env.DULO_API || 'https://dulo.tv/api/live-tv/channels';
+
+function isHttpUrl(url: unknown): boolean {
+ if (typeof url !== 'string') return false;
+ try {
+ const u = new URL(url);
+ return u.protocol === 'https:' || u.protocol === 'http:';
+ } catch {
+ return false;
+ }
+}
+
+function toIso(ts: unknown): string | null {
+ if (!ts || typeof ts !== 'string') return null;
+ const d = new Date(ts);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+}
+
+const duloAdapter: SourceAdapter = {
+ id: 'dulo',
+ label: 'dulo',
+
+ // Prefer the live catalog API; fall back to the captured snapshot when offline / region-blocked.
+ async listChannels() {
+ try {
+ const res = await fetch(DULO_API, { headers: { Origin: DULO_ORIGIN } });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const body = (await res.json()) as { channels?: any[] };
+ const raw = body.channels || [];
+ if (!raw.length) throw new Error('empty channel list');
+ return { raw, meta: { endpoint: DULO_API, live: true, fetchedAt: new Date().toISOString() } };
+ } catch (err) {
+ const snap = JSON.parse(readFileSync(SNAPSHOT, 'utf8')) as { channels?: any[] };
+ return {
+ raw: snap.channels || [],
+ meta: {
+ endpoint: DULO_API,
+ live: false,
+ fallback: 'dulo.snapshot.json',
+ reason: (err as Error).message,
+ fetchedAt: new Date().toISOString(),
+ },
+ };
+ }
+ },
+
+ normalize(raw: any, { ingestedAt }): SourceChannelDoc | null {
+ const sourceChannelId = String(raw.id);
+ const category = raw.category || null;
+ return {
+ _id: `dulo:${sourceChannelId}`,
+ source: 'dulo',
+ sourceChannelId,
+ name: raw.name,
+ category, // dulo has real semantic categories
+ groupKey: category || 'uncategorized',
+ groupLabel: category || 'uncategorized',
+ logoUrl: raw.logo_url || null,
+ streamEntryUrl: raw.source_url, // token-free master .m3u8 (handed straight to the proxy)
+ isPlayable: isHttpUrl(raw.source_url),
+ sourceCreatedAt: toIso(raw.created_at),
+ sourceUpdatedAt: toIso(raw.updated_at),
+ ingestedAt,
+ };
+ },
+
+ grouping: { by: 'groupKey', groupOrder: 'alpha', channelOrder: 'name' },
+
+ isEntryUrl() {
+ return false; // dulo source_url is already the master — nothing to resolve
+ },
+ async resolveStream(entryUrl: string) {
+ return { masterUrl: entryUrl }; // identity no-op
+ },
+
+ proxy: {
+ upstreamHeaders() {
+ return { Origin: DULO_ORIGIN }; // the memfs Origin allowlist gate
+ },
+ isAllowedUpstream(url: string) {
+ try {
+ const u = new URL(url);
+ return (u.protocol === 'https:' || u.protocol === 'http:') && u.hostname.endsWith('.dulo.tv');
+ } catch {
+ return false;
+ }
+ },
+ onPlaylistChildHost: null, // static allowlist — nothing to learn at runtime
+ relabelSegmentContentType(_url: string, contentType: string) {
+ return contentType || 'application/octet-stream'; // plain TS — pass the upstream type through
+ },
+ classifyArtifact(url: string) {
+ try {
+ const p = new URL(url).pathname.toLowerCase();
+ if (p.endsWith('.ts')) return 'segment';
+ if (p.endsWith('.m3u8')) return p.includes('_output_') ? 'variant' : 'master';
+ return 'other';
+ } catch {
+ return 'other';
+ }
+ },
+ },
+};
+
+export default duloAdapter;
diff --git a/server/src/sources/core/buildSource.ts b/server/src/sources/core/buildSource.ts
new file mode 100644
index 00000000..d9fd3cb4
--- /dev/null
+++ b/server/src/sources/core/buildSource.ts
@@ -0,0 +1,54 @@
+// The generic "standard function" pipeline, run once per source for a LIVE sync:
+// listChannels() → raw upstream records → normalize(raw) → one SourceChannel doc each
+// → dedupe by deterministic _id (last wins, idempotent) → return docs for upsert.
+//
+// Ported from d-combine/lib/core/build-source.mjs, but returns the docs instead of writing files —
+// the seed module upserts them into Mongo. The whole file is source-agnostic.
+
+import { logger } from './logger.js';
+import type { SourceAdapter } from '../types.js';
+import type { SourceChannelDoc } from '../../models/SourceChannel.js';
+
+export interface BuildResult {
+ id: string;
+ count: number;
+ docs: SourceChannelDoc[];
+ live: boolean;
+ meta: Record;
+}
+
+export async function buildSource(adapter: SourceAdapter): Promise {
+ const startedAt = Date.now();
+ logger.info('build', `[${adapter.id}] fetching channel listings…`);
+
+ const { raw, meta = {} } = await adapter.listChannels();
+ logger.info(
+ 'build',
+ `[${adapter.id}] got ${raw.length} raw records (${meta.live === false ? 'offline snapshot' : 'live'})`,
+ );
+
+ const ingestedAt = new Date().toISOString();
+ const normalized: SourceChannelDoc[] = [];
+ for (const record of raw) {
+ try {
+ const doc = adapter.normalize(record, { ingestedAt });
+ if (doc) normalized.push(doc);
+ } catch (err) {
+ logger.warn('build', `[${adapter.id}] skipped a record: ${(err as Error).message}`);
+ }
+ }
+
+ // Dedupe by deterministic _id (last wins) — guards against duplicate upstream rows.
+ const byId = new Map();
+ for (const doc of normalized) byId.set(doc._id, doc);
+ const docs = [...byId.values()];
+
+ logger.ok('build', `[${adapter.id}] normalized ${docs.length} docs`);
+ return {
+ id: adapter.id,
+ count: docs.length,
+ docs,
+ live: meta.live !== false,
+ meta: { ...meta, buildMs: Date.now() - startedAt },
+ };
+}
diff --git a/server/src/sources/core/logger.ts b/server/src/sources/core/logger.ts
new file mode 100644
index 00000000..d6b437b0
--- /dev/null
+++ b/server/src/sources/core/logger.ts
@@ -0,0 +1,18 @@
+// Tiny tagged logger matching the existing console style ("[mongo] connected", "[api] …").
+// Ported from d-combine/lib/core/logger.mjs, trimmed to what the core uses.
+
+type Level = 'info' | 'warn' | 'error' | 'ok';
+
+function emit(level: Level, tag: string, msg: string): void {
+ const line = `[${tag}] ${msg}`;
+ if (level === 'error') console.error(line);
+ else if (level === 'warn') console.warn(line);
+ else console.info(line);
+}
+
+export const logger = {
+ info: (tag: string, msg: string) => emit('info', tag, msg),
+ warn: (tag: string, msg: string) => emit('warn', tag, msg),
+ error: (tag: string, msg: string) => emit('error', tag, msg),
+ ok: (tag: string, msg: string) => emit('ok', tag, msg),
+};
diff --git a/server/src/sources/core/metrics.ts b/server/src/sources/core/metrics.ts
new file mode 100644
index 00000000..6c164e15
--- /dev/null
+++ b/server/src/sources/core/metrics.ts
@@ -0,0 +1,52 @@
+// In-memory counters, one set PER SOURCE. Surfaced via /api/sources/:id/status and /health-style
+// reporting. Ported from d-combine/lib/core/metrics.mjs.
+
+export interface Metrics {
+ startedAt: number;
+ requests: {
+ total: number;
+ master: number;
+ variant: number;
+ segment: number;
+ other: number;
+ errors: number;
+ };
+ upstream: { ok: number; notLive: number; forbidden: number; failed: number };
+ bytesStreamed: number;
+ active: number; // proxy requests currently in flight
+ lastStreamAt: number | null;
+ lastError: string | null;
+}
+
+export function createMetrics(): Metrics {
+ return {
+ startedAt: Date.now(),
+ requests: { total: 0, master: 0, variant: 0, segment: 0, other: 0, errors: 0 },
+ upstream: { ok: 0, notLive: 0, forbidden: 0, failed: 0 }, // 404=notLive, 403=forbidden(gate)
+ bytesStreamed: 0,
+ active: 0,
+ lastStreamAt: null,
+ lastError: null,
+ };
+}
+
+/** Human-readable byte size. */
+export function fmtBytes(n: number): string {
+ if (n < 1024) return `${n}B`;
+ if (n < 1048576) return `${(n / 1024).toFixed(1)}KB`;
+ return `${(n / 1048576).toFixed(2)}MB`;
+}
+
+/** JSON snapshot of one source's metrics. */
+export function snapshotOne(m: Metrics) {
+ return {
+ uptimeSeconds: Math.round((Date.now() - m.startedAt) / 1000),
+ active: m.active,
+ requests: { ...m.requests },
+ upstream: { ...m.upstream },
+ bytesStreamed: m.bytesStreamed,
+ mbStreamed: +(m.bytesStreamed / 1048576).toFixed(2),
+ lastStreamAt: m.lastStreamAt ? new Date(m.lastStreamAt).toISOString() : null,
+ lastError: m.lastError,
+ };
+}
diff --git a/server/src/sources/core/playlist.ts b/server/src/sources/core/playlist.ts
new file mode 100644
index 00000000..e71002ba
--- /dev/null
+++ b/server/src/sources/core/playlist.ts
@@ -0,0 +1,45 @@
+// Shared HLS playlist helpers. Ported from d-combine/lib/core/playlist.mjs. The only per-source
+// difference — whether the rewriter also learns (allowlists) each child host — is the `onChildHost`
+// hook param, so one implementation serves every source.
+
+/** True if the upstream URL / content-type looks like an HLS playlist (.m3u8). */
+export function looksLikePlaylist(upstreamUrl: string, contentType: string): boolean {
+ if (contentType && contentType.includes('mpegurl')) return true; // apple.mpegurl / x-mpegurl
+ try {
+ return new URL(upstreamUrl).pathname.toLowerCase().endsWith('.m3u8');
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Rewrite every child URI in a playlist so it routes back through this proxy.
+ *
+ * @param text the raw playlist body
+ * @param baseUrl the upstream URL it was fetched from (for relative→absolute)
+ * @param prefix proxy mount prefix to prepend, e.g. "/api/v1/dulo/"
+ * @param onChildHost per-child-host hook (dlhd dynamic-allow; dulo/common null)
+ */
+export function rewritePlaylist(
+ text: string,
+ baseUrl: string,
+ prefix: string,
+ onChildHost: ((host: string) => void) | null,
+): string {
+ return text
+ .split(/\r?\n/)
+ .map((rawLine) => {
+ const trimmed = rawLine.trim();
+ if (!trimmed || trimmed.startsWith('#')) return rawLine; // tag / comment / blank → as-is
+ const abs = new URL(trimmed, baseUrl).href; // resolve relative → absolute
+ if (onChildHost) {
+ try {
+ onChildHost(new URL(abs).hostname);
+ } catch {
+ /* ignore malformed */
+ }
+ }
+ return `${prefix}${encodeURIComponent(abs)}`;
+ })
+ .join('\n');
+}
diff --git a/server/src/sources/core/proxyHandler.ts b/server/src/sources/core/proxyHandler.ts
new file mode 100644
index 00000000..e46eaf79
--- /dev/null
+++ b/server/src/sources/core/proxyHandler.ts
@@ -0,0 +1,162 @@
+// One Express handler factory per source, bound to GET /api/v1/:source/*. Ported from
+// d-combine/lib/core/proxy-handler.mjs. Every per-source difference (resolve, headers, SSRF allow,
+// dynamic-allow, segment relabel, artifact classification) is read off `adapter`; the control flow
+// below is invariant.
+//
+// /api/v1//
+// · entry URL (dlhd watch.php / stream-N.php) → adapter.resolveStream() → fresh master, then proxy
+// · master/variant .m3u8 → rewrite child URIs back through /api/v1//…
+// · segment → pipe bytes (adapter may relabel the content-type)
+
+import { Readable } from 'node:stream';
+import type { Request, Response } from 'express';
+import { logger } from './logger.js';
+import { fmtBytes, type Metrics } from './metrics.js';
+import { looksLikePlaylist, rewritePlaylist } from './playlist.js';
+import type { SourceAdapter } from '../types.js';
+
+function label(url: string): { host: string; short: string } {
+ try {
+ const u = new URL(url);
+ const file = u.pathname.split('/').pop() || '';
+ return { host: u.hostname, short: file.slice(0, 8) || '/' };
+ } catch {
+ return { host: '?', short: '?' };
+ }
+}
+
+export function createProxyHandler(adapter: SourceAdapter, metrics: Metrics) {
+ // Marker used to slice the raw (still-encoded) upstream URL out of req.originalUrl, independent of
+ // where the router is mounted. Keeps embedded ?session=/?md5&expires through ONE decodeURIComponent.
+ const MARKER = `/v1/${adapter.id}/`;
+ const PREFIX = `/api/v1/${adapter.id}/`;
+ const tag = `stream:${adapter.id}`;
+ const { proxy } = adapter;
+
+ return async function handler(req: Request, res: Response): Promise {
+ const startedAt = Date.now();
+ const ms = () => `${Date.now() - startedAt}ms`;
+
+ // 1. Extract + decode the single percent-encoded upstream URL segment.
+ const idx = req.originalUrl.indexOf(MARKER);
+ const rawPath = idx >= 0 ? req.originalUrl.slice(idx + MARKER.length) : '';
+ let upstreamUrl: string;
+ try {
+ upstreamUrl = decodeURIComponent(rawPath);
+ } catch {
+ logger.warn(tag, `400 malformed encoded URL from ${req.ip}`);
+ res.status(400).type('text/plain').send('Bad request: malformed encoded URL');
+ return;
+ }
+
+ // 2. Resolve-then-proxy: an entry URL must become a fresh stream URL first
+ // (dulo/common: never — entry IS the master; dlhd: the 3-hop scrape).
+ if (adapter.isEntryUrl(upstreamUrl)) {
+ metrics.requests.total++;
+ metrics.requests.master++;
+ try {
+ const resolved = await adapter.resolveStream(upstreamUrl);
+ upstreamUrl = resolved.masterUrl;
+ } catch (err) {
+ metrics.requests.errors++;
+ metrics.upstream.notLive++;
+ metrics.lastError = (err as Error).message;
+ logger.warn(tag, `resolve failed: ${(err as Error).message} (${ms()})`);
+ res.status(502).type('text/plain').send(`Resolve failed: ${(err as Error).message}`);
+ return;
+ }
+ } else {
+ // 3. Direct hop (master/variant/segment from a rewritten playlist) → SSRF gate.
+ if (!proxy.isAllowedUpstream(upstreamUrl)) {
+ logger.warn(tag, `400 blocked upstream: ${String(upstreamUrl).slice(0, 80)}`);
+ res.status(400).type('text/plain').send('Bad request: upstream host not in the allowlist');
+ return;
+ }
+ metrics.requests.total++;
+ metrics.requests[proxy.classifyArtifact(upstreamUrl)]++;
+ }
+
+ const type = proxy.classifyArtifact(upstreamUrl);
+ const { host, short } = label(upstreamUrl);
+
+ metrics.active++;
+ res.on('close', () => {
+ metrics.active--;
+ });
+
+ let upstream: Awaited>;
+ try {
+ upstream = await fetch(upstreamUrl, { headers: proxy.upstreamHeaders(upstreamUrl) });
+ } catch (err) {
+ metrics.upstream.failed++;
+ metrics.requests.errors++;
+ metrics.lastError = (err as Error).message;
+ logger.error(tag, `${type} ${host} ${short} upstream fetch failed: ${(err as Error).message} (${ms()})`);
+ res.status(502).type('text/plain').send(`Upstream fetch failed: ${(err as Error).message}`);
+ return;
+ }
+
+ // Forward upstream errors verbatim. 404 = not transcoding right now; 403 = origin/referer gate.
+ if (!upstream.ok) {
+ metrics.requests.errors++;
+ if (upstream.status === 404) metrics.upstream.notLive++;
+ else if (upstream.status === 403) metrics.upstream.forbidden++;
+ else metrics.upstream.failed++;
+ const note = upstream.status === 404 ? ' (not live)' : upstream.status === 403 ? ' (gate)' : '';
+ metrics.lastError = `HTTP ${upstream.status} ${host}/${short}`;
+ logger.warn(tag, `${type} ${host} ${short} status=${upstream.status}${note} (${ms()})`);
+ const detail = await upstream.text().catch(() => '');
+ res
+ .status(upstream.status)
+ .type(upstream.headers.get('content-type') || 'text/plain')
+ .send(detail || `Upstream HTTP ${upstream.status}`);
+ return;
+ }
+
+ const contentType = upstream.headers.get('content-type') || '';
+
+ // 4. Playlist → rewrite child URIs back through this source's proxy prefix
+ // (and let the adapter learn each child host: dlhd dynamic-allow; dulo/common no-op).
+ if (looksLikePlaylist(upstreamUrl, contentType)) {
+ const rewritten = rewritePlaylist(await upstream.text(), upstreamUrl, PREFIX, proxy.onPlaylistChildHost);
+ const bytes = Buffer.byteLength(rewritten);
+ metrics.upstream.ok++;
+ metrics.bytesStreamed += bytes;
+ metrics.lastStreamAt = Date.now();
+ logger.ok(tag, `${type} ${host} ${short} status=200 ${fmtBytes(bytes)} (${ms()})`);
+ res.set('Cache-Control', 'no-store'); // playlists + tokens are short-lived
+ res.type('application/vnd.apple.mpegurl').send(rewritten);
+ return;
+ }
+
+ // 5. Segment (or anything else) → stream the bytes through, content-type per the adapter.
+ res.set('Content-Type', proxy.relabelSegmentContentType(upstreamUrl, contentType, type));
+ res.set('Cache-Control', 'no-store');
+
+ if (!upstream.body) {
+ metrics.upstream.ok++;
+ logger.ok(tag, `${type} ${host} ${short} status=200 0B (${ms()})`);
+ res.end();
+ return;
+ }
+
+ let bytes = 0;
+ const body = Readable.fromWeb(upstream.body as Parameters[0]);
+ body.on('data', (chunk: Buffer) => {
+ bytes += chunk.length;
+ });
+ res.on('finish', () => {
+ metrics.upstream.ok++;
+ metrics.bytesStreamed += bytes;
+ metrics.lastStreamAt = Date.now();
+ logger.ok(tag, `${type} ${host} ${short} status=200 ${fmtBytes(bytes)} (${ms()})`);
+ });
+ body.on('error', (err: Error) => {
+ metrics.upstream.failed++;
+ metrics.lastError = err.message;
+ logger.error(tag, `${type} ${host} ${short} stream error: ${err.message}`);
+ res.destroy(err);
+ });
+ body.pipe(res);
+ };
+}
diff --git a/server/src/sources/paths.ts b/server/src/sources/paths.ts
new file mode 100644
index 00000000..9806ac18
--- /dev/null
+++ b/server/src/sources/paths.ts
@@ -0,0 +1,19 @@
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+// This module lives at /sources/paths.{ts,js} in BOTH dev (server/src) and prod (server/dist).
+// The bundled seed assets sit at server/seed-data/sources — i.e. two levels up from here, then
+// seed-data/sources. The Docker runtime stage copies server/seed-data → /app/seed-data alongside
+// /app/dist, so this same relative resolution holds in the container.
+const here = dirname(fileURLToPath(import.meta.url));
+
+/** Directory holding the committed .source.json baselines + .snapshot.json fallbacks. */
+export const SEED_SOURCES_DIR = resolve(here, '..', '..', 'seed-data', 'sources');
+
+export function bundleFile(sourceId: string): string {
+ return resolve(SEED_SOURCES_DIR, `${sourceId}.source.json`);
+}
+
+export function snapshotFile(sourceId: string): string {
+ return resolve(SEED_SOURCES_DIR, `${sourceId}.snapshot.json`);
+}
diff --git a/server/src/sources/registry.ts b/server/src/sources/registry.ts
new file mode 100644
index 00000000..d443f7d7
--- /dev/null
+++ b/server/src/sources/registry.ts
@@ -0,0 +1,13 @@
+// The single enumeration of source adapters TVApp2 knows about. Ported from
+// ../d-combine/sources/registry.mjs. Adding a source (Phase 2: common, Phase 3: dlhd) = write a new
+// adapter under adapters/ and add it here; the boot init, sources router (manifest + proxy mounts),
+// and SPA all iterate this list, so nothing else needs to change.
+
+import duloAdapter from './adapters/dulo.js';
+import type { SourceAdapter } from './types.js';
+
+export const SOURCES: SourceAdapter[] = [duloAdapter];
+
+export function getSource(id: string): SourceAdapter | undefined {
+ return SOURCES.find((s) => s.id === id);
+}
diff --git a/server/src/sources/seed.ts b/server/src/sources/seed.ts
new file mode 100644
index 00000000..4466435f
--- /dev/null
+++ b/server/src/sources/seed.ts
@@ -0,0 +1,161 @@
+// Seed / init / sync / reset for the established (Default) source playlists.
+//
+// "Both: bundle + live sync" — the committed .source.json bundle is the GUARANTEED baseline used
+// to initialize/reset MongoDB (offline-safe, reproducible); a live sync then refreshes channels and
+// the Playlist's sync metadata from upstream when reachable. Boot init (ensureSeeded + background
+// syncLive via bootInitSources) runs once per source in server/src/index.ts after the Mongo connect,
+// covering both Docker variants without extra orchestration.
+
+import { readFileSync } from 'node:fs';
+import { Playlist } from '../models/Playlist.js';
+import { SourceChannel, type SourceChannelDoc } from '../models/SourceChannel.js';
+import { SOURCES, getSource } from './registry.js';
+import { buildSource } from './core/buildSource.js';
+import { bundleFile } from './paths.js';
+import { logger } from './core/logger.js';
+import type { SourceAdapter } from './types.js';
+
+export interface IntegrityReport {
+ id: string;
+ playlistExists: boolean;
+ channelCount: number;
+ ok: boolean;
+ issues: string[];
+}
+
+function readBundle(id: string): SourceChannelDoc[] {
+ const arr = JSON.parse(readFileSync(bundleFile(id), 'utf8'));
+ if (!Array.isArray(arr)) throw new Error(`bundle for "${id}" is not a JSON array`);
+ return arr as SourceChannelDoc[];
+}
+
+function groupCount(docs: SourceChannelDoc[]): number {
+ return new Set(docs.map((d) => d.groupKey)).size;
+}
+
+// Idempotent upsert by _id. Strips _id from $set (immutable; supplied by the filter on insert).
+async function upsertChannels(docs: SourceChannelDoc[]): Promise {
+ if (!docs.length) return;
+ const ops = docs.map((d) => {
+ const { _id, ...rest } = d;
+ return { updateOne: { filter: { _id }, update: { $set: rest }, upsert: true } };
+ });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await SourceChannel.bulkWrite(ops as any[], { ordered: false });
+}
+
+async function upsertPlaylistRow(
+ adapter: SourceAdapter,
+ groups: number,
+ opts: { lastSync: string; status: string },
+): Promise {
+ const row = {
+ id: adapter.id,
+ source: adapter.id,
+ name: `(Default) ${adapter.label}`,
+ url: `source://${adapter.id}`,
+ groups,
+ lastSync: opts.lastSync,
+ status: opts.status,
+ auto: true,
+ interval: 'Auto-updated',
+ builtin: true,
+ };
+ await Playlist.updateOne({ id: adapter.id }, { $set: row }, { upsert: true });
+}
+
+export async function validateIntegrity(id: string): Promise {
+ const issues: string[] = [];
+ const playlist = await Playlist.findOne({ id }).lean();
+ const channelCount = await SourceChannel.countDocuments({ source: id });
+ if (!playlist) issues.push('playlist row missing');
+ if (channelCount === 0) issues.push('no channels');
+ const sample = await SourceChannel.findOne({ source: id }).lean();
+ if (sample) {
+ for (const f of ['name', 'streamEntryUrl', 'groupKey'] as const) {
+ if (!sample[f]) issues.push(`a channel is missing required field "${f}"`);
+ }
+ }
+ return { id, playlistExists: !!playlist, channelCount, ok: issues.length === 0, issues };
+}
+
+/** Initialize a source from its committed bundle (idempotent upsert). */
+export async function initFromBundle(id: string): Promise {
+ const adapter = getSource(id);
+ if (!adapter) throw new Error(`unknown source: ${id}`);
+ const docs = readBundle(id);
+ await upsertChannels(docs);
+ await upsertPlaylistRow(adapter, groupCount(docs), { lastSync: 'Ships with TVApp2', status: 'good' });
+ logger.ok('seed', `[${id}] initialized ${docs.length} channels from bundle`);
+ return validateIntegrity(id);
+}
+
+/** Restore a source EXACTLY to its committed bundle (drops stale channels, then reinserts). */
+export async function resetFromBundle(id: string): Promise {
+ const adapter = getSource(id);
+ if (!adapter) throw new Error(`unknown source: ${id}`);
+ const docs = readBundle(id);
+ await SourceChannel.deleteMany({ source: id });
+ await SourceChannel.insertMany(docs, { ordered: false });
+ await upsertPlaylistRow(adapter, groupCount(docs), { lastSync: 'Reset from bundle', status: 'good' });
+ logger.ok('seed', `[${id}] reset ${docs.length} channels from bundle`);
+ return validateIntegrity(id);
+}
+
+/** Live refresh: run the adapter's build pipeline and upsert; updates Playlist sync metadata. */
+export async function syncLive(
+ id: string,
+): Promise<{ report: IntegrityReport; live: boolean; count: number }> {
+ const adapter = getSource(id);
+ if (!adapter) throw new Error(`unknown source: ${id}`);
+ const result = await buildSource(adapter);
+ await upsertChannels(result.docs);
+ await upsertPlaylistRow(adapter, groupCount(result.docs), {
+ lastSync: new Date().toISOString(),
+ status: result.live ? 'good' : 'warn',
+ });
+ logger.ok(
+ 'seed',
+ `[${id}] live sync upserted ${result.count} channels (${result.live ? 'live' : 'snapshot'})`,
+ );
+ const report = await validateIntegrity(id);
+ return { report, live: result.live, count: result.count };
+}
+
+/** Boot guard: seed from bundle only if the (Default) playlist is missing or empty. */
+export async function ensureSeeded(id: string): Promise {
+ const report = await validateIntegrity(id);
+ if (report.playlistExists && report.channelCount > 0) {
+ logger.info('seed', `[${id}] already seeded (${report.channelCount} channels) — skipping init`);
+ return report;
+ }
+ logger.info('seed', `[${id}] not seeded — initializing from bundle`);
+ return initFromBundle(id);
+}
+
+/**
+ * Run once at startup for every registered source: guarantee the bundle baseline synchronously, then
+ * kick a non-blocking live sync (Both mode). A failed/slow live sync must NEVER block or crash boot —
+ * the bundle baseline already satisfies init.
+ */
+export async function bootInitSources(opts: { liveSync?: boolean } = {}): Promise {
+ const liveSync = opts.liveSync ?? true;
+ for (const adapter of SOURCES) {
+ try {
+ const report = await ensureSeeded(adapter.id);
+ if (!report.ok) {
+ logger.warn('seed', `[${adapter.id}] integrity issues after init: ${report.issues.join(', ')}`);
+ }
+ if (liveSync) {
+ void syncLive(adapter.id).catch((err) =>
+ logger.warn(
+ 'seed',
+ `[${adapter.id}] live sync failed (keeping bundle baseline): ${(err as Error).message}`,
+ ),
+ );
+ }
+ } catch (err) {
+ logger.error('seed', `[${adapter.id}] init failed: ${(err as Error).message}`);
+ }
+ }
+}
diff --git a/server/src/sources/translate.ts b/server/src/sources/translate.ts
new file mode 100644
index 00000000..63138a78
--- /dev/null
+++ b/server/src/sources/translate.ts
@@ -0,0 +1,69 @@
+// Translation layer: project a canonical SourceChannel doc into the legacy UI Channel shape the Vue
+// screens consume. Fields with no source equivalent are returned as explicit null (never fabricated)
+// per the agreed schema-reconciliation decision; logoUrl + streamEntryUrl + isPlayable are added so
+// the SPA can render real logos and play through the proxy. The frontend derives the proxy path from
+// (source, streamEntryUrl); it is intentionally not stored.
+
+import type { SourceChannelDoc } from '../models/SourceChannel.js';
+
+export interface UiChannel {
+ id: string; // ":"
+ tvg_name: string;
+ group: string;
+ channel: number | null; // no source channel number
+ tvg_id: string | null;
+ state: 'active' | 'disabled';
+ epg: 'matched' | 'unmatched' | null; // no EPG matching yet
+ status: string | null; // unknown until a stream is probed
+ res: string | null; // unknown until a stream is probed
+ source: string;
+ url: string; // streamEntryUrl (legacy field name)
+ logoColor: string; // derived deterministic fallback
+ initials: string; // derived from name
+ logoUrl: string | null; // real logo when the source provides one (dlhd: null)
+ streamEntryUrl: string; // explicit, for the player / proxyPath derivation
+ isPlayable: boolean;
+}
+
+// Deterministic hue from a stable string → keeps a channel's fallback logo color stable across syncs.
+function hueFromString(s: string): number {
+ let h = 0;
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
+ return h % 360;
+}
+
+function logoColorFor(id: string): string {
+ return `oklch(0.5 0.16 ${hueFromString(id)})`;
+}
+
+function initialsFor(name: string): string {
+ const ini = name
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((w) => w[0])
+ .join('')
+ .toUpperCase();
+ return ini || '?';
+}
+
+export function toUiChannel(doc: SourceChannelDoc): UiChannel {
+ return {
+ id: doc._id,
+ tvg_name: doc.name,
+ group: doc.groupLabel,
+ channel: null,
+ tvg_id: null,
+ state: doc.isPlayable ? 'active' : 'disabled',
+ epg: null,
+ status: null,
+ res: null,
+ source: doc.source,
+ url: doc.streamEntryUrl,
+ logoColor: logoColorFor(doc._id),
+ initials: initialsFor(doc.name),
+ logoUrl: doc.logoUrl,
+ streamEntryUrl: doc.streamEntryUrl,
+ isPlayable: doc.isPlayable,
+ };
+}
diff --git a/server/src/sources/types.ts b/server/src/sources/types.ts
new file mode 100644
index 00000000..75cc28fd
--- /dev/null
+++ b/server/src/sources/types.ts
@@ -0,0 +1,55 @@
+// The source-adapter contract, ported from d-combine (sources//adapter.mjs). One object per
+// source captures ONLY what differs between sources; the generic core (buildSource, proxyHandler,
+// playlist) consumes any adapter without per-source branching. Adding a source = one adapter +
+// one registry line.
+
+import type { SourceChannelDoc } from '../models/SourceChannel.js';
+
+export interface SourceMeta {
+ live?: boolean;
+ [k: string]: unknown;
+}
+
+export interface RawListing {
+ // upstream-shaped records (JSON API rows, scraped cards, …) — the adapter boundary is untyped.
+ raw: any[];
+ meta?: SourceMeta;
+}
+
+export type ArtifactType = 'master' | 'variant' | 'segment' | 'other';
+
+export interface SourceGrouping {
+ by: string;
+ groupOrder: string;
+ channelOrder: string;
+}
+
+export interface SourceProxy {
+ /** Headers to inject on every upstream hop (dulo: Origin; dlhd: Referer+UA). */
+ upstreamHeaders(url: string): Record;
+ /** SSRF gate for direct hops (dulo: *.dulo.tv; dlhd: dynamic Set; common: block private IPs). */
+ isAllowedUpstream(url: string): boolean;
+ /** Per-rewritten-child hook (dlhd: dynamic-allow each host; dulo/common: null). */
+ onPlaylistChildHost: ((host: string) => void) | null;
+ /** dulo/common: pass-through; dlhd: relabel disguised image/pdf TS as video/mp2t. */
+ relabelSegmentContentType(url: string, contentType: string, type?: ArtifactType): string;
+ classifyArtifact(url: string): ArtifactType;
+}
+
+export interface SourceAdapter {
+ id: string;
+ label: string;
+ /** Fetch/scrape raw listings → { raw, meta }; falls back to a bundled snapshot when offline. */
+ listChannels(): Promise;
+ /** Map one raw record → one normalized document, or null to drop it. */
+ normalize(raw: any, ctx: { ingestedAt: string }): SourceChannelDoc | null;
+ /** Serializable UI descriptor read by the SPA over /api/sources. */
+ grouping: SourceGrouping;
+ /** Optional runtime provenance (dlhd: active mirror + probes). Absent → manifest statusUrl null. */
+ status?: () => unknown | Promise;
+ /** Does this URL need server-side resolution before proxying? (dulo/common: false; dlhd: watch.php) */
+ isEntryUrl(url: string): boolean;
+ /** Entry URL → { masterUrl }. dulo/common: identity; dlhd: 3-hop scrape. */
+ resolveStream(entryUrl: string): Promise<{ masterUrl: string }>;
+ proxy: SourceProxy;
+}
diff --git a/server/tsconfig.json b/server/tsconfig.json
new file mode 100644
index 00000000..11e67d73
--- /dev/null
+++ b/server/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "forceConsistentCasingInFileNames": true,
+ "declaration": false,
+ "sourceMap": true
+ },
+ "include": ["src/**/*"]
+}
diff --git a/src/App.vue b/src/App.vue
new file mode 100644
index 00000000..9d65fffd
--- /dev/null
+++ b/src/App.vue
@@ -0,0 +1,207 @@
+
+
+
+
+
+
+
+
+ {{ crumbs.title }}
+
+
+
+
+ Restoring
+ {{ restoreJob.label }}
+
+
+
{{ restoreJob.percent }}%
+
+
+
+
+
+ {{ crumbs.parent }}
+ ›
+
+ {{ crumbs.crumb }}
+
+
+
+
+
+
+
+
+
+
+ New source
+
+
+
+
+ addOpen = k" />
+
+
+
+
+
+
+
+
+
+
+ setTweak('theme', v as any)" />
+
+ setTweak('density', v as any)" />
+
+ setTweak('epgMode', v as any)" />
+
+
+
diff --git a/src/components/AddSourceModal.vue b/src/components/AddSourceModal.vue
new file mode 100644
index 00000000..428f5c04
--- /dev/null
+++ b/src/components/AddSourceModal.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
Add {{ label }}
+
+
+
+
+
+
+
+
+
+
+
+ Tip: you can also drag a file into the
+ Import screen.
+
+
+
+ Cancel
+ Add & sync
+
+
+
+
diff --git a/src/components/Btn.vue b/src/components/Btn.vue
new file mode 100644
index 00000000..384f16d0
--- /dev/null
+++ b/src/components/Btn.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/src/components/ChannelBulkDrawer.vue b/src/components/ChannelBulkDrawer.vue
new file mode 100644
index 00000000..67525a2d
--- /dev/null
+++ b/src/components/ChannelBulkDrawer.vue
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
Edit {{ channels.length }} channels
+
+ Apply changes to all selected channels
+
+
+
+
+
+
+
+
+
+
Channels being edited
+
+
{{ channels.length }}
+
+
+ #{{ c.channel }}
+ {{ c.tvg_name }}
+ · {{ c.group }}
+
+
+ + {{ channels.length - 8 }} more
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+ Apply to {{ channels.length }} channels
+
+
+
+
+
+
diff --git a/src/components/ChannelDrawer.vue b/src/components/ChannelDrawer.vue
new file mode 100644
index 00000000..ac4ec96f
--- /dev/null
+++ b/src/components/ChannelDrawer.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
{{ ch.tvg_name }}
+
+ #{{ ch.channel ?? '—' }} · {{ ch.group }} · {{ ch.res }}
+
+
+
+
+
+
+
+
+
+
+
+
+
STREAM TEST · {{ ch.res }}
+
+
+
+
+
+
stream live
+
1280×720
+
h.264 / AAC
+
+
Re-test
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Remove
+
+ Cancel
+ Save changes
+
+
+
+
+
diff --git a/src/components/ChannelLogo.vue b/src/components/ChannelLogo.vue
new file mode 100644
index 00000000..f23b4b0c
--- /dev/null
+++ b/src/components/ChannelLogo.vue
@@ -0,0 +1,27 @@
+
+
+
+
+ {{ ch.initials }}
+
+
diff --git a/src/components/Checkbox.vue b/src/components/Checkbox.vue
new file mode 100644
index 00000000..b7fab7ed
--- /dev/null
+++ b/src/components/Checkbox.vue
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/src/components/EndpointField.vue b/src/components/EndpointField.vue
new file mode 100644
index 00000000..a82cae24
--- /dev/null
+++ b/src/components/EndpointField.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+ {{ copied ? 'Copied' : 'Copy' }}
+
+
+
diff --git a/src/components/HlsPlayer.vue b/src/components/HlsPlayer.vue
new file mode 100644
index 00000000..84e79922
--- /dev/null
+++ b/src/components/HlsPlayer.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
diff --git a/src/components/Icon.vue b/src/components/Icon.vue
new file mode 100644
index 00000000..537fd099
--- /dev/null
+++ b/src/components/Icon.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/LogsDrawer.vue b/src/components/LogsDrawer.vue
new file mode 100644
index 00000000..a6f8b1bd
--- /dev/null
+++ b/src/components/LogsDrawer.vue
@@ -0,0 +1,195 @@
+
+
+
+
+
+
+
+
+
+
Realtime logs
+
+ STREAMING
+
+
paused
+
+ {{ counts.total }} lines · {{ counts.warn }} warn · {{ counts.error }} err
+
+
+
search = v" placeholder="Filter logs" :width="220" />
+
+
+ All sources
+ {{ s }}
+
+
+ filter = v" :options="[
+ { value: 'all', label: 'All' },
+ { value: 'info', label: 'Info' },
+ { value: 'issues', label: 'Issues' },
+ { value: 'debug', label: 'Debug' },
+ ]" />
+
+
+ {{ paused ? 'Resume' : 'Pause' }}
+
+ Clear
+ Export
+
+
+
+
+
No log lines match
+
Adjust the search or level filter.
+
+
+
+ {{ fmtTime(l.ts) }}
+ {{ l.level.toUpperCase() }}
+ {{ l.source }}
+ {{ l.text }}
+
+
+
+
+ Jump to live
+
+
+
+
diff --git a/src/components/Pill.vue b/src/components/Pill.vue
new file mode 100644
index 00000000..85b2cac8
--- /dev/null
+++ b/src/components/Pill.vue
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/src/components/PlaylistStatusDrawer.vue b/src/components/PlaylistStatusDrawer.vue
new file mode 100644
index 00000000..a4988bd8
--- /dev/null
+++ b/src/components/PlaylistStatusDrawer.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
Playlist status
+
{{ playlist.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Summary
+
+
+
+
EPG matched
+
+
{{ matched }}
+
+
+
+
EPG unmatched
+
+
{{ unmatched }}
+
+
+
+
+
+
Channels per category
+
+
{{ perGroup.length }}
+
+
+ {{ g }}
+
+ {{ n }}
+
+
+
+
+
+
+ Done
+
+
+
+
+
diff --git a/src/components/SearchInput.vue b/src/components/SearchInput.vue
new file mode 100644
index 00000000..a5d48b60
--- /dev/null
+++ b/src/components/SearchInput.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/src/components/Segmented.vue b/src/components/Segmented.vue
new file mode 100644
index 00000000..99073ff6
--- /dev/null
+++ b/src/components/Segmented.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
+ {{ o.label }}
+
+
+
diff --git a/src/components/SettingsRow.vue b/src/components/SettingsRow.vue
new file mode 100644
index 00000000..de0159e2
--- /dev/null
+++ b/src/components/SettingsRow.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
{{ label }}
+
{{ hint }}
+
+
+
+
diff --git a/src/components/Sparkline.vue b/src/components/Sparkline.vue
new file mode 100644
index 00000000..da03a8aa
--- /dev/null
+++ b/src/components/Sparkline.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ −60m −45m −30m −15m now
+
+
+
diff --git a/src/components/Stat.vue b/src/components/Stat.vue
new file mode 100644
index 00000000..40607d99
--- /dev/null
+++ b/src/components/Stat.vue
@@ -0,0 +1,11 @@
+
+
+
+
{{ label }}
+
+ {{ value }}
+
+
+
diff --git a/src/components/StatusDot.vue b/src/components/StatusDot.vue
new file mode 100644
index 00000000..15c28b20
--- /dev/null
+++ b/src/components/StatusDot.vue
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/src/components/Toggle.vue b/src/components/Toggle.vue
new file mode 100644
index 00000000..c2bb51a4
--- /dev/null
+++ b/src/components/Toggle.vue
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/src/components/TweakRadio.vue b/src/components/TweakRadio.vue
new file mode 100644
index 00000000..7c2721da
--- /dev/null
+++ b/src/components/TweakRadio.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/src/components/TweakSection.vue b/src/components/TweakSection.vue
new file mode 100644
index 00000000..aee672dc
--- /dev/null
+++ b/src/components/TweakSection.vue
@@ -0,0 +1,6 @@
+
+
+ {{ label }}
+
diff --git a/src/components/TweaksPanel.vue b/src/components/TweaksPanel.vue
new file mode 100644
index 00000000..6d829382
--- /dev/null
+++ b/src/components/TweaksPanel.vue
@@ -0,0 +1,103 @@
+
+
+
+
+
+ {{ title || 'Tweaks' }}
+ ✕
+
+
+
+
+
+
diff --git a/src/composables/bus.ts b/src/composables/bus.ts
new file mode 100644
index 00000000..a6884090
--- /dev/null
+++ b/src/composables/bus.ts
@@ -0,0 +1,8 @@
+import mitt from 'mitt';
+
+export interface RestoreItem { kind: string; text: string }
+type Events = {
+ 'tvapp:restore-start': { items: RestoreItem[] };
+ 'tvapp:restore-done': void;
+};
+export const bus = mitt();
diff --git a/src/composables/useSettings.ts b/src/composables/useSettings.ts
new file mode 100644
index 00000000..212a3c76
--- /dev/null
+++ b/src/composables/useSettings.ts
@@ -0,0 +1,34 @@
+import { ref, computed, reactive } from 'vue';
+
+export const displayName = ref('TVApp2 Workspace');
+export const domain = ref('https://tvapp2.example.com');
+export const m3uPath = ref('/m3u/playlist.m3u8');
+export const epgPath = ref('/epg/guide.xml.gz');
+
+export const m3uEndpoint = computed(() => `${domain.value.replace(/\/$/, '')}${m3uPath.value.startsWith('/') ? '' : '/'}${m3uPath.value}`);
+export const epgEndpoint = computed(() => `${domain.value.replace(/\/$/, '')}${epgPath.value.startsWith('/') ? '' : '/'}${epgPath.value}`);
+
+export interface PlaylistStatus {
+ active: boolean;
+ endpointMode: 'global' | 'custom';
+ customPath: string;
+}
+
+const _status = reactive>({});
+
+export function usePlaylistStatus(id: string): PlaylistStatus {
+ if (!_status[id]) {
+ _status[id] = { active: true, endpointMode: 'global', customPath: `/playlists/${id}.m3u` };
+ }
+ return _status[id];
+}
+
+export function playlistEndpoint(id: string): string {
+ const s = usePlaylistStatus(id);
+ const base = domain.value.replace(/\/$/, '');
+ if (s.endpointMode === 'custom') {
+ const p = s.customPath.startsWith('/') ? s.customPath : `/${s.customPath}`;
+ return `${base}${p}`;
+ }
+ return m3uEndpoint.value;
+}
diff --git a/src/composables/useTweaks.ts b/src/composables/useTweaks.ts
new file mode 100644
index 00000000..00d140ce
--- /dev/null
+++ b/src/composables/useTweaks.ts
@@ -0,0 +1,34 @@
+import { reactive, watch } from 'vue';
+
+export interface Tweaks {
+ theme: 'dark' | 'light';
+ density: 'compact' | 'regular' | 'spacious';
+ epgMode: 'timeline' | 'list';
+}
+
+const tweaks = reactive({
+ theme: 'dark',
+ density: 'regular',
+ epgMode: 'timeline',
+});
+
+watch(
+ () => [tweaks.theme, tweaks.density] as const,
+ ([theme, density]) => {
+ document.documentElement.dataset.theme = theme;
+ document.documentElement.dataset.density = density;
+ },
+ { immediate: true }
+);
+
+export function useTweaks() {
+ function setTweak(key: K, value: Tweaks[K]): void;
+ function setTweak(edits: Partial): void;
+ function setTweak(keyOrEdits: any, val?: any) {
+ const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
+ ? keyOrEdits : { [keyOrEdits]: val };
+ Object.assign(tweaks, edits);
+ try { window.parent?.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); } catch {}
+ }
+ return { tweaks, setTweak };
+}
diff --git a/src/data.ts b/src/data.ts
new file mode 100644
index 00000000..15ba099c
--- /dev/null
+++ b/src/data.ts
@@ -0,0 +1,143 @@
+// Reactive store for TVApp2.
+//
+// Top-level data (PLAYLISTS, CHANNELS, ACTIVE_STREAMS, etc.) is fetched from
+// the API at app startup via bootstrapData(). Consumers in
+
+
+
+
+
+
Live now
+
{{ totals.streams }} / {{ liveStreams.length }}
+
relaying
+
+
+
Viewers
+
{{ totals.viewers }}
+
peak 412 today
+
+
+
Egress
+
{{ (totals.bandwidth / 1000).toFixed(2) }} Gbps
+
across 3 edge nodes
+
+
+
Issues
+
{{ totals.issues }}
+
+ needs attention
+ all healthy
+
+
+
+
+
+
+
+ {}" placeholder="Search streams" :width="180" />
+
+ filter = v as any" :options="[
+ { value: 'all', label: 'All' },
+ { value: 'live', label: 'Live' },
+ { value: 'issues', label: 'Issues' },
+ ]" />
+
+
+
+
+
+
+ {{ chOf(s).tvg_name }}
+
+
+
+
+
+ {{ s.status === 'bad' ? 'offline' : s.resolution }}
+ ·
+ {{ s.status === 'bad' ? '—' : s.bitrate.toFixed(1) + ' Mbps' }}
+ ·
+ {{ s.uptime }}
+
+
+
+ {{ s.viewers }}
+ viewers
+
+
+
+
+
+
+
+
+
+
+
+
{{ chOf(sel).tvg_name }}
+
LIVE
+
offline
+
degraded
+
+
+ #{{ chOf(sel).channel }} · {{ chOf(sel).group }} · stream-id {{ sel.id }}
+
+
+
Restart
+
+ {{ sel.status === 'bad' ? 'Start' : 'Stop' }}
+
+
View channel
+
+
+
+
+
Viewers
+
{{ sel.viewers }}
+
peak {{ sel.peakViewers }} · session
+
+
+
Bitrate
+
{{ sel.status === 'bad' ? '—' : sel.bitrate.toFixed(1) }} Mbps
+
target {{ sel.targetBitrate.toFixed(1) }} Mbps
+
+
+
Uptime
+
{{ sel.uptime }}
+
since {{ sel.status === 'bad' ? '—' : 'started' }}
+
+
+
Bandwidth
+
{{ sel.bandwidth }} Mbps
+
egress · {{ sel.viewers }} client{{ sel.viewers === 1 ? '' : 's' }}
+
+
+
+
+
+
Bitrate · last 60 min
+
+
avg {{ (selSeries.reduce((a, b) => a + b, 0) / selSeries.length).toFixed(1) }} Mbps
+
min {{ Math.min(...selSeries).toFixed(1) }}
+
max {{ Math.max(...selSeries).toFixed(1) }}
+
+
+
+
+
+
+
Technical
+
+
Video
{{ sel.codec }}
+
Audio
{{ sel.audio }}
+
Container
{{ sel.container }}
+
Resolution
{{ sel.resolution }} @ {{ sel.fps }}fps
+
Latency
{{ sel.latency.toFixed(1) }} s
+
Dropped
+ {{ sel.droppedFrames }} frames · {{ (sel.droppedRatio * 100).toFixed(2) }}%
+ ● high
+
+
+
+
+
Source
+
+
Upstream
+
{{ sel.sourceUrl }}
+
Edge node
{{ sel.sourceHost }}
+
Protocol
HLS · HTTPS
+
TVG-ID
+
+ {{ chOf(sel).tvg_id }}
+ —
+
+
Source
+
+
EPG
+
+
+
+
+
+
+
+
Connected sessions
+
{{ sel.status === 'bad' ? 0 : sel.viewers }}
+
+
+
+
+
No viewers — stream is offline.
+
+
+
+
+ IP Region Client Bitrate
+ Joined
+
+
+
+
+ {{ s.ip }}
+ {{ s.region }}
+ {{ s.client }}
+ {{ s.bitrate }}
+ {{ s.joined }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ chOf(viewStream).tvg_name }}
+
LIVE
+
offline
+
+
+ #{{ chOf(viewStream).channel }} · {{ chOf(viewStream).group }} ·
+ {{ viewStream.status === 'bad' ? 'no signal' : viewStream.resolution + ' · ' + viewStream.bitrate.toFixed(1) + ' Mbps' }}
+
+
+
+
+
+
+
+
+
+
+
+
Stream offline
+
upstream returned HTTP 503
+
Retry source
+
+
+
+
+
+ {{ viewStream.resolution }} · {{ viewStream.fps }}fps · {{ viewStream.bitrate.toFixed(1) }} Mbps
+
+
+
+
+
+
{{ playing ? '01:42' : '00:00' }}
+
+
+
+
+
LIVE
+
+
+
+
+
+
+
+
From the guide
+
+ EPG-matched
+
+
+
+
ON NOW
+
{{ npData(viewStream.channelId).live!.title }}
+
+ {{ formatTime(npData(viewStream.channelId).live!.start) }}–{{ formatTime(npData(viewStream.channelId).live!.end) }} · {{ npData(viewStream.channelId).live!.cat }}
+
+
+
+
UP NEXT
+
{{ npData(viewStream.channelId).next!.title }}
+
+ {{ formatTime(npData(viewStream.channelId).next!.start) }}–{{ formatTime(npData(viewStream.channelId).next!.end) }} · {{ npData(viewStream.channelId).next!.cat }}
+
+
+
+
+
+
+
Viewers
{{ viewStream.viewers }}
+
Bitrate
{{ viewStream.status === 'bad' ? '—' : viewStream.bitrate.toFixed(1) + ' Mbps' }}
+
Latency
{{ viewStream.status === 'bad' ? '—' : viewStream.latency.toFixed(1) + 's' }}
+
Uptime
{{ viewStream.uptime }}
+
+
+
+
+
Stream details
+
+
+
+ {{ viewStream.status === 'bad' ? 'offline' : viewStream.status === 'warn' ? 'degraded' : 'healthy' }}
+
+
+
+
+
Video
{{ viewStream.codec }}
+
Audio
{{ viewStream.audio }}
+
Container
{{ viewStream.container }}
+
Resolution
{{ viewStream.resolution }} @ {{ viewStream.fps }}fps
+
Dropped
+ {{ viewStream.droppedFrames }} frames · {{ (viewStream.droppedRatio * 100).toFixed(2) }}%
+ ● high
+
+
Bandwidth
{{ viewStream.bandwidth }} Mbps egress
+
Edge node
{{ viewStream.sourceHost }}
+
Source URL
+
{{ viewStream.sourceUrl }}
+
TVG-ID
+
+ {{ chOf(viewStream).tvg_id }}
+ —
+
+
Source
+
{{ chOf(viewStream).source }}
+
+
+
+
+
+ Restart stream
+ Edit channel
+
+ Stop
+
+
+
+
+
+
diff --git a/src/screens/DashboardScreen.vue b/src/screens/DashboardScreen.vue
new file mode 100644
index 00000000..05ee1a3e
--- /dev/null
+++ b/src/screens/DashboardScreen.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
Playlists
+
{{ PLAYLISTS.length }}
+
all syncing
+
+
+
Channels
+
{{ totalChannels }}
+
12 new this week
+
+
+
EPG sources
+
{{ EPG_SOURCES.length }}
+
{{ totalPrograms.toLocaleString() }} programs
+
+
+
Unmatched
+
{{ unmatched }}
+
needs mapping
+
+
+
+
+
+
+
+
+
Playlists
+
{{ PLAYLISTS.length }}
+
+
View all
+
Add playlist
+
+
+
+
+
+
+
+ {{ p.name }}
+
+
built-in
+
+
{{ p.url }}
+
+
{{ p.channels }} channels
+
{{ p.groups }} groups
+
+ {{ p.lastSync }}
+ last sync
+
+
+
+
+
+
+
+
+
EPG Sources
+
{{ EPG_SOURCES.length }}
+
+
View all
+
Add EPG source
+
+
+
+
+
+
+
+ {{ p.name }}
+
+
built-in
+
+
{{ p.url }}
+
+
{{ p.channels }} channels
+
{{ p.programs.toLocaleString() }} programs
+
+ {{ p.lastSync }}
+ last sync
+
+
+
+
+
+
+
+
+
Activity
+
+ Last 24h
+
+
+
+
+
+
diff --git a/src/screens/EPGDetailScreen.vue b/src/screens/EPGDetailScreen.vue
new file mode 100644
index 00000000..7a6bd909
--- /dev/null
+++ b/src/screens/EPGDetailScreen.vue
@@ -0,0 +1,332 @@
+
+
+
+
+
+
+
+
+
+
+
{{ epg.name }}
+
+
built-in
+
{{ epg.interval }}
+
+
{{ epg.url }}
+
+ Ships with TVApp2 · guide data is preconfigured and auto-updated with the app.
+
+
+
+
+
+
+
+
Sync now
+
+
+
+
+
+
+
+
+
Channel
+
+
+ {{ String(h).padStart(2, '0') }}:00
+
+
+
+
+
+
+
+
+
+
{{ c.tvg_name }}
+
#{{ c.channel }}
+
+
+
+
+
{{ p.title }}
+
{{ formatTime(p.start) }}–{{ formatTime(p.end) }} · {{ p.cat }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ c.tvg_name }}
+
#{{ c.channel }} · {{ c.group }}
+
+
+
+ on now: {{ livePr(c)!.title }}
+
+
+
+
+
+ {{ formatTime(p.start) }}–{{ formatTime(p.end) }}
+
+
+ {{ p.title }}
+
+
{{ p.cat }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ viewing.channel.tvg_name }}
+
LIVE
+
upcoming
+
aired
+
+
+ #{{ viewing.channel.channel }} · {{ viewing.channel.group }} · {{ viewing.channel.res }}
+
+
+
+
+
+
+
+
+
+
+
+
Programme has ended
+
aired {{ formatTime(viewing.prog.start) }}–{{ formatTime(viewing.prog.end) }}
+
Check on-demand
+
+
+
+
+
+
+
+
Starts at {{ formatTime(viewing.prog.start) }}
+
in {{ humanizeDelta(viewing.prog.start - now) }}
+
Set reminder
+
+
+
+
+
+ {{ viewing.channel.res }} · LIVE
+
+
+
+
{{ formatTime(now) }}
+
+
{{ formatTime(viewing.prog.end) }}
+
+
+
+
+
+
+ {{ progState(viewing.prog) === 'live' ? 'ON NOW' : progState(viewing.prog) === 'upcoming' ? 'UP NEXT' : 'EARLIER TODAY' }} · {{ viewing.prog.cat }}
+
+
{{ viewing.prog.title }}
+
+
{{ formatTime(viewing.prog.start) }}–{{ formatTime(viewing.prog.end) }}
+
{{ humanizeDur(viewing.prog.end - viewing.prog.start) }}
+
{{ viewing.prog.cat }}
+
+
+ {{ Math.round(Math.min(1, Math.max(0, (now - viewing.prog.start) / (viewing.prog.end - viewing.prog.start))) * 100) }}% elapsed · {{ humanizeDelta(viewing.prog.end - now) }} left
+
+
+
+
+
+
+
+ {{ blurbs[viewing.prog.cat] || 'A scheduled programme on this channel.' }}
+
+
+
+
+
Programme details
+
+
Channel
+
{{ viewing.channel.tvg_name }} · #{{ viewing.channel.channel }}
+
Group
{{ viewing.channel.group }}
+
Time
{{ formatTime(viewing.prog.start) }} – {{ formatTime(viewing.prog.end) }}
+
Duration
{{ humanizeDur(viewing.prog.end - viewing.prog.start) }}
+
Category
{{ viewing.prog.cat }}
+
Resolution
{{ viewing.channel.res }}
+
TVG-ID
+
+ {{ viewing.channel.tvg_id }}
+ —
+
+
Source
+
{{ viewing.channel.source }}
+
EPG match
+
+
+
+
+
+ Set reminder
+ Watch live
+ Check catch-up
+ Open channel
+ Channel guide
+
+
+
+
+
+
+
+
diff --git a/src/screens/EPGSourcesScreen.vue b/src/screens/EPGSourcesScreen.vue
new file mode 100644
index 00000000..adf92e43
--- /dev/null
+++ b/src/screens/EPGSourcesScreen.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+ {}" placeholder="Search EPG sources" />
+
+ Sync all
+ Add EPG source
+
+
+
+
+
+
+
+ {{ p.name }}
+
+
built-in
+
{{ p.interval }}
+
+
{{ p.url }}
+
+
{{ p.channels }} channels
+
{{ p.programs.toLocaleString() }} programs
+
+ {{ p.lastSync }}
+ last sync
+
+
+
+
+
+
diff --git a/src/screens/HistoryMetricsScreen.vue b/src/screens/HistoryMetricsScreen.vue
new file mode 100644
index 00000000..f9523274
--- /dev/null
+++ b/src/screens/HistoryMetricsScreen.vue
@@ -0,0 +1,393 @@
+
+
+
+
+
+
+
+
Streaming history & metrics
+
+ Past viewer sessions across all channels — identify channels with frequent rebuffering or playback issues.
+
+
+
+
range = v" :options="[
+ { value: '1h', label: '1h' },
+ { value: '24h', label: '24h' },
+ { value: '7d', label: '7d' },
+ { value: '30d', label: '30d' },
+ ]" />
+ Export
+
+
+
+
+
Sessions
+
{{ sessions.length }}
+
{{ uniqueChannels }} channels · {{ uniqueIps }} unique IPs
+
+
+
Watch time
+
{{ formatDur(totalMinutes) }}
+
avg {{ sessions.length ? Math.round(totalMinutes / sessions.length) : 0 }}m / session
+
+
+
Rebuffer ratio
+
+ {{ rebuffRatio.toFixed(2) }}%
+
+
+ {{ totalBuffers }} buffer events · {{ formatMs(totalRebuffMs) }} total
+
+
+
+
QoE score
+
+ {{ avgScore }} / 100
+
+
{{ sessions.filter((s) => s.health === 'bad').length }} problem sessions
+
+
+
+
+
+
+
Buffer events · last 24h
+
+
{{ totalBuffers }} total
+
{{ Math.max(...bufBins) }} peak / hour
+
+
+
+
+ −24h −18h −12h −6h now
+
+
+
+
+
+
Problem channels
+
+
by QoE score
+
+
+
+
+
+
{{ c.ch.tvg_name }}
+
{{ c.sessions }} sessions · {{ c.buffers }} buffers
+
+
+ {{ c.avgScore }}
+
+
+
+
+
+
+
+
+
+ search = v" placeholder="Channel or IP" :width="200" />
+
+ health = v as any" :options="[
+ { value: 'all', label: 'All' },
+ { value: 'good', label: 'Good' },
+ { value: 'warn', label: 'Warn' },
+ { value: 'bad', label: 'Bad' },
+ ]" />
+
+
+
+
+ Channel IP / Region Started
+ Duration Buffers QoE
+
+
+
+
+
+
+
+
+
{{ chOf(s).tvg_name }}
+
#{{ chOf(s).channel }}
+
+
+
+
+ {{ s.ip }}
+ {{ s.region }}
+
+ {{ formatAgo(s.startedAgo) }}
+
+ {{ formatDur(s.duration) }}
+ live
+
+
+
+ {{ s.buffers }}
+ · {{ formatMs(s.rebuffMs) }}
+
+
+
+
+ {{ s.score }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ chOf(sel).tvg_name }}
+
#{{ chOf(sel).channel }} · session {{ sel.id }}
+
+
+ QoE {{ sel.score }}
+
+
+
+
+
+
Duration
+
{{ formatDur(sel.duration) }}
+
started {{ formatAgo(sel.startedAgo) }}
+
+
+
Avg bitrate
+
{{ sel.avgBitrate }} Mbps
+
{{ sel.resolution }} · {{ sel.codec }}
+
+
+
Rebuffer
+
{{ formatMs(sel.rebuffMs) }}
+
{{ sel.buffers }} events · {{ sel.dropped }} drops
+
+
+
+
+
+
Client IP
{{ sel.ip }}
+
Region
{{ sel.region }}
+
Player
{{ sel.client }}
+
Resolution
{{ sel.resolution }} · {{ sel.codec }}
+
Channel #
#{{ chOf(sel).channel }}
+
Group
{{ chOf(sel).group }}
+
TVG-ID
+
+ {{ chOf(sel).tvg_id }}
+ —
+
+
Source
+
+
+
+
+
+
+
Buffering timeline
+
+
0 → {{ formatDur(sel.duration) }}
+
+
+
+
+ No buffer events
+
+
+
+
+
+
+
+ +{{ e.at }}m
+ {{ formatMs(e.dur) }}
+ {{ e.cause }}
+
+
+ {{ events.length - 6 }} more
+
+
+
+
No session
+
+
+
+
diff --git a/src/screens/ImportScreen.vue b/src/screens/ImportScreen.vue
new file mode 100644
index 00000000..6b848f6c
--- /dev/null
+++ b/src/screens/ImportScreen.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+ M3U Playlist
+
+
+ EPG / XMLTV
+
+
+
+
+
Source
+
+ Upload file
+ Remote URL
+
+
+
+
+
+
Drop {{ tab === 'playlist' ? 'an M3U/M3U8' : 'an XMLTV' }} file here
+
or click to browse — up to 50 MB
+
+
+
+
+
+
+
+
+
{{ file.name }}
+
+ {{ file.size }} · {{ progress < 100 ? 'parsing…' : 'ready' }}
+
+
+
{{ progress }}%
+
parsed
+
+
+
+
+
+
+
{{ tab === 'playlist' ? '142 channels detected' : '8,420 programs detected' }}
+
{{ tab === 'playlist' ? '8 groups' : '124 channels' }}
+
+
+ Cancel
+
+ Import {{ tab === 'playlist' ? 'playlist' : 'EPG' }}
+
+
+
+
+
+
+
+
+
diff --git a/src/screens/MappingScreen.vue b/src/screens/MappingScreen.vue
new file mode 100644
index 00000000..5e601f95
--- /dev/null
+++ b/src/screens/MappingScreen.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
Channel ↔ EPG mapping
+
+ Drag from left to right, or pick a channel and click the EPG ID. Auto-match runs nightly.
+
+
+
+
+
Auto-match
+
+
+
+
+
+ M3U Channels
+
+
+ Unmatched
+ Matched
+ All
+
+
+
+
+
+
{{ c.tvg_name }}
+
+ {{ mappings[c.id] }}
+
+
+
unmatched
+
+
+
All matched 🎉
+
Every channel in this view has an EPG ID assigned.
+
+
+
+
+
+
+
+
+
+
+ EPG channel IDs
+
+ {{ epgChannelIds.length }}
+
+
+
{ if (selL && !usedBy(e.id)) { link(selL, e.id); selL = null; } }">
+
{{ e.name }}
+
{{ e.id }}
+
linked
+
click to link
+
+
+
+
+
+
diff --git a/src/screens/PlaylistDetailScreen.vue b/src/screens/PlaylistDetailScreen.vue
new file mode 100644
index 00000000..d9995ebb
--- /dev/null
+++ b/src/screens/PlaylistDetailScreen.vue
@@ -0,0 +1,527 @@
+
+
+
+
+
+
+
+
+
+
+
{{ playlist.name }}
+
+
built-in
+
{{ playlist.interval }}
+
+
{{ playlist.url }}
+
+ Ships with TVApp2 · channels are preconfigured and auto-updated with the app.
+
+
+
+
+ Status
+
+ {{ plStatus.active ? 'Active' : 'Inactive' }}
+
+
+
+
+
+
+
+ {{ syncing ? 'Syncing…' : 'Sync now' }}
+
+
Sync now
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+ Channel
+ Group
+ Source
+ TVG-ID
+ State
+ EPG
+ Stream
+
+
+
+
+
+
+
+
+ {{ c.channel ?? '—' }}
+
+
+
+
+
{{ c.tvg_name }}
+
{{ c.res }}
+
+
+ {{ c.group }}
+ {{ c.source }}
+
+ {{ c.tvg_id }}
+ —
+
+
+
+ {{ c.state === 'active' ? 'active' : 'disabled' }}
+
+
+
+ matched
+ no match
+ —
+
+
+
+
+
+
+ {{ c.status === 'good' ? 'live' : c.status === 'warn' ? 'slow' : 'down' }}
+
+
+ —
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ c.tvg_name }}
+
#{{ c.channel ?? '—' }} · {{ c.res }}
+
+
+
{{ c.group }}
+
+
+ {{ c.state === 'active' ? 'active' : 'disabled' }}
+
+
EPG
+
no EPG
+
{{ c.source }}
+
+
+
+
+
+
+
+
+
+
+
+
+
New custom playlist
+
+
+
+
+
+
+
+ {{ selectedChannels.length }}
+ selected channel{{ selectedChannels.length === 1 ? '' : 's' }} will be added to the new playlist.
+
+
+
+
+
+
+
+
+
+
+
Channels to include
+
+
{{ selectedChannels.length }}
+
+
+ #{{ c.channel }}
+ {{ c.tvg_name }}
+ · {{ c.group }}
+
+
+ + {{ selectedChannels.length - 8 }} more
+
+
+
+
+
+ Cancel
+ Create playlist
+
+
+
+
+
+
+
+
+
+
Append to custom playlist
+
+
+
+
+
+
+
+ {{ selectedChannels.length }}
+ selected channel{{ selectedChannels.length === 1 ? '' : 's' }} will be appended to the playlist you choose.
+
+
+
+
+
No custom playlists yet
+
+ Use Create to make your first custom playlist.
+
+
+
+
+
+
+
+
+
+
+
+
{{ target.name }}
+
/playlists/{{ target.slug }}.m3u
+
+
updated {{ target.updated }}
+
+
+
+
→
+
+ {{ newTotal }}
+
+
+
+{{ selectedChannels.length }}
+
+
+
+
+
+
+ Cancel
+
+ Append {{ selectedChannels.length }} channel{{ selectedChannels.length === 1 ? '' : 's' }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ toast.text }}
+
+
+
+
+
+
diff --git a/src/screens/PlaylistsScreen.vue b/src/screens/PlaylistsScreen.vue
new file mode 100644
index 00000000..96d4641a
--- /dev/null
+++ b/src/screens/PlaylistsScreen.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+ {}" placeholder="Search playlists" />
+
+ Sync all
+ Add playlist
+
+
+
+
+
+
+
+ {{ p.name }}
+
+
built-in
+
{{ p.interval }}
+
+
{{ p.url }}
+
+
+ {{ usePlaylistStatus(p.id).active ? 'Active' : 'Inactive' }}
+
+
{{ p.channels }} channels
+
{{ p.groups }} groups
+
+ {{ p.lastSync }}
+ last sync
+
+
+
+
+
diff --git a/src/screens/SettingsScreen.vue b/src/screens/SettingsScreen.vue
new file mode 100644
index 00000000..32779606
--- /dev/null
+++ b/src/screens/SettingsScreen.vue
@@ -0,0 +1,369 @@
+
+
+
+
+
+
General
+
+
+
+
+
Hosting endpoints
+
+ Public URLs where TVApp2 will expose the consolidated M3U playlist and EPG guide to your downstream apps.
+
+
+
m3uPath = v.startsWith(domain.replace(/\/$/, '')) ? v.slice(domain.replace(/\/$/, '').length) : v"
+ mono />
+
+ epgPath = v.startsWith(domain.replace(/\/$/, '')) ? v.slice(domain.replace(/\/$/, '').length) : v"
+ mono />
+
+
+
+
Syncing
+
+
+
+
+
+
autoSync = v" />
+
+
+
+
+
+
+ Playlists
+
+ {{ PLAYLISTS.length }} sources · {{ tz }}
+
+
+ Sync all
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ x.label }}
+ Custom…
+ Custom: {{ schedules[p.id].cron }}
+
+
+
+
+
+ {{ schedules[p.id].cron }}
+
+
+
+ updateSched(p.id, { enabled: v })" />
+
+
+
+
+
+
+
+ EPG Sources
+
+ {{ EPG_SOURCES.length }} sources · {{ tz }}
+
+
+ Sync all
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ x.label }}
+ Custom…
+ Custom: {{ schedules[e.id].cron }}
+
+
+
+
+
+ {{ schedules[e.id].cron }}
+
+
+
+ updateSched(e.id, { enabled: v })" />
+
+
+
+
+
+
+
+
+
+
autoMatch = v" />
+
+
+
+
+
+
+
+
Data
+
+ Export all sources
+ Rebuild EPG index
+ Clear cache
+
+
+
+
+ Restored
+ Restoring…
+ Restore defaults
+
+
+
+
+
+
+
+
Restore built-in sources?
+
+
+
+
+
+ TVApp2 will re-add the default playlist and EPG sources that ship with the app.
+ If any of them were previously hidden or removed, they will reappear in your workspace.
+
+
+
+
+
+
+
{{ group.title }}
+
+
{{ group.items.length }}
+
+
+
+ {{ it.name }}
+ {{ it.url }}
+
+
+
+
+
+
+
+ Your custom playlists, EPG sources, channel mappings, and viewing history will not be modified.
+
+
+
+
+
+ Cancel
+ Confirm restore
+
+
+
+
+
+
+ Reset workspace
+
+
+
+
diff --git a/src/shims-vue.d.ts b/src/shims-vue.d.ts
new file mode 100644
index 00000000..64c3fd9e
--- /dev/null
+++ b/src/shims-vue.d.ts
@@ -0,0 +1,5 @@
+declare module '*.vue' {
+ import type { DefineComponent } from 'vue';
+ const component: DefineComponent<{}, {}, any>;
+ export default component;
+}
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 00000000..8a8c5063
--- /dev/null
+++ b/src/styles.css
@@ -0,0 +1,1762 @@
+/* ── Design tokens ────────────────────────────────────────────── */
+:root {
+ /* Dark (default) */
+ --bg-0: oklch(0.16 0.005 240);
+ --bg-1: oklch(0.20 0.006 240);
+ --bg-2: oklch(0.24 0.008 240);
+ --bg-3: oklch(0.28 0.010 240);
+ --hairline: oklch(1 0 0 / 0.07);
+ --hairline-strong: oklch(1 0 0 / 0.12);
+
+ --text-0: oklch(0.98 0 0);
+ --text-1: oklch(0.78 0.01 240);
+ --text-2: oklch(0.58 0.012 240);
+ --text-3: oklch(0.42 0.012 240);
+
+ --accent: oklch(0.82 0.13 220);
+ --accent-hi: oklch(0.88 0.13 220);
+ --accent-soft: oklch(0.82 0.13 220 / 0.14);
+ --accent-glow: oklch(0.82 0.13 220 / 0.35);
+
+ --good: oklch(0.78 0.16 150);
+ --warn: oklch(0.82 0.15 80);
+ --bad: oklch(0.70 0.18 25);
+
+ --radius-s: 8px;
+ --radius-m: 12px;
+ --radius-l: 16px;
+
+ /* Density (set per [data-density]) */
+ --row-h: 44px;
+ --pad-card: 18px;
+ --pad-x: 16px;
+ --gap: 14px;
+ --fs-base: 13.5px;
+ --fs-sm: 12px;
+ --fs-xs: 11px;
+ --fs-h: 22px;
+ --fs-h2: 16px;
+}
+
+[data-density="compact"] {
+ --row-h: 36px;
+ --pad-card: 14px;
+ --pad-x: 12px;
+ --gap: 10px;
+ --fs-base: 12.5px;
+ --fs-sm: 11.5px;
+ --fs-xs: 10.5px;
+ --fs-h: 19px;
+ --fs-h2: 14.5px;
+}
+[data-density="spacious"] {
+ --row-h: 52px;
+ --pad-card: 22px;
+ --pad-x: 20px;
+ --gap: 18px;
+ --fs-base: 14.5px;
+ --fs-sm: 13px;
+ --fs-xs: 11.5px;
+ --fs-h: 26px;
+ --fs-h2: 18px;
+}
+
+[data-theme="light"] {
+ --bg-0: oklch(0.985 0.003 240);
+ --bg-1: oklch(1 0 0);
+ --bg-2: oklch(0.965 0.004 240);
+ --bg-3: oklch(0.94 0.005 240);
+ --hairline: oklch(0 0 0 / 0.08);
+ --hairline-strong: oklch(0 0 0 / 0.14);
+ --text-0: oklch(0.18 0.01 240);
+ --text-1: oklch(0.32 0.012 240);
+ --text-2: oklch(0.50 0.012 240);
+ --text-3: oklch(0.62 0.012 240);
+
+ /* Darker semantic colors so foreground text stays legible on light backgrounds.
+ The originals (used in dark mode) are too washed out on white. */
+ --accent: oklch(0.52 0.15 220);
+ --accent-hi: oklch(0.44 0.16 220);
+ --accent-soft: oklch(0.52 0.15 220 / 0.12);
+ --accent-glow: oklch(0.52 0.15 220 / 0.22);
+ --good: oklch(0.48 0.17 150);
+ --warn: oklch(0.55 0.17 60);
+ --bad: oklch(0.52 0.20 25);
+}
+
+/* Hardcoded raw-oklch text colors that bypass the tokens — re-shade in light mode */
+[data-theme="light"] .log-line.log-error .log-msg { color: oklch(0.48 0.20 25); }
+[data-theme="light"] .live-pill { color: oklch(0.48 0.20 25); background: oklch(0.52 0.20 25 / 0.12); border-color: oklch(0.52 0.20 25 / 0.4); }
+
+/* Builtin source/schedule icons use a dark navy gradient by default — swap to a
+ light surface in light mode so the row matches its siblings, while preserving
+ the cyan icon color and accent glow. */
+[data-theme="light"] .src-row .src-ico.builtin,
+[data-theme="light"] .sched-ico.builtin {
+ background: var(--bg-3);
+ border-color: oklch(0.52 0.15 220 / 0.3);
+ box-shadow: inset 0 0 0 1px oklch(0.52 0.15 220 / 0.15), 0 0 18px oklch(0.52 0.15 220 / 0.18);
+}
+[data-theme="light"] .src-row .src-ico.builtin.epg-builtin,
+[data-theme="light"] .src-ico.builtin.epg-builtin,
+[data-theme="light"] .sched-ico.builtin.epg-builtin {
+ background: var(--bg-3);
+ border-color: oklch(0.48 0.17 150 / 0.3);
+ box-shadow: inset 0 0 0 1px oklch(0.48 0.17 150 / 0.15), 0 0 18px oklch(0.48 0.17 150 / 0.18);
+}
+
+/* ── Reset / base ─────────────────────────────────────────────── */
+* { box-sizing: border-box; }
+html, body, #root { height: 100%; margin: 0; }
+body {
+ font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, "Helvetica Neue", sans-serif;
+ font-size: var(--fs-base);
+ color: var(--text-0);
+ background: var(--bg-0);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ letter-spacing: -0.005em;
+}
+button { font: inherit; color: inherit; }
+input, textarea, select { font: inherit; color: inherit; }
+button, input, textarea, select { outline: none; }
+::selection { background: var(--accent-soft); color: var(--text-0); }
+
+/* Scrollbars */
+*::-webkit-scrollbar { width: 10px; height: 10px; }
+*::-webkit-scrollbar-track { background: transparent; }
+*::-webkit-scrollbar-thumb {
+ background: var(--hairline-strong);
+ border-radius: 6px;
+ border: 2px solid transparent;
+ background-clip: content-box;
+}
+*::-webkit-scrollbar-thumb:hover { background: var(--text-3); background-clip: content-box; border: 2px solid transparent; }
+
+/* ── App shell ────────────────────────────────────────────────── */
+.app {
+ display: grid;
+ grid-template-columns: 224px 1fr;
+ height: 100vh;
+ min-width: 1100px;
+}
+
+/* Sidebar */
+.sidebar {
+ background: var(--bg-0);
+ border-right: 1px solid var(--hairline);
+ padding: 20px 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ overflow: hidden;
+}
+.brand {
+ padding: 4px 8px 8px;
+ font-weight: 700;
+ font-size: 19px;
+ letter-spacing: -0.02em;
+ color: var(--accent);
+ text-shadow: 0 0 24px var(--accent-glow);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.brand-dot {
+ width: 8px; height: 8px; border-radius: 50%;
+ background: var(--accent);
+ box-shadow: 0 0 12px var(--accent);
+}
+.nav-group-label {
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ padding: 6px 10px;
+ margin-top: 6px;
+}
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 10px;
+ border-radius: var(--radius-s);
+ color: var(--text-1);
+ cursor: default;
+ font-size: var(--fs-base);
+ transition: background .12s, color .12s;
+ user-select: none;
+}
+.nav-item:hover { background: var(--bg-1); color: var(--text-0); }
+.nav-item.active {
+ background: var(--accent-soft);
+ color: var(--accent-hi);
+ box-shadow: inset 0 0 0 1px oklch(0.82 0.13 220 / 0.25);
+}
+.nav-item .ico { width: 16px; height: 16px; flex: none; opacity: 0.9; }
+.nav-item .count {
+ margin-left: auto;
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ color: var(--text-3);
+ background: var(--bg-1);
+ padding: 1px 7px;
+ border-radius: 999px;
+}
+.nav-item.active .count { background: oklch(0.82 0.13 220 / 0.2); color: var(--accent-hi); }
+
+.sidebar-foot {
+ margin-top: auto;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px;
+ border-radius: var(--radius-s);
+ background: var(--bg-1);
+ border: 1px solid var(--hairline);
+}
+.avatar {
+ width: 28px; height: 28px; border-radius: 50%;
+ background: linear-gradient(135deg, oklch(0.82 0.13 220), oklch(0.6 0.18 280));
+ flex: none;
+ display: grid; place-items: center;
+ color: white; font-weight: 700; font-size: 11px;
+}
+.sidebar-foot .name { font-size: 12.5px; color: var(--text-0); }
+.sidebar-foot .plan { font-size: 10.5px; color: var(--text-2); }
+
+/* Main */
+.main {
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-0);
+ overflow: hidden;
+}
+.topbar {
+ height: 56px;
+ flex: none;
+ border-bottom: 1px solid var(--hairline);
+ display: flex;
+ align-items: center;
+ padding: 0 var(--pad-x);
+ gap: 12px;
+}
+.topbar h1 {
+ margin: 0;
+ font-size: var(--fs-h);
+ font-weight: 600;
+ letter-spacing: -0.018em;
+}
+.topbar .crumb {
+ color: var(--text-2);
+ font-size: var(--fs-sm);
+}
+.topbar-spacer { flex: 1; }
+
+/* Header restore-progress strip — spans from title to the theme toggle while a
+ "Restore defaults" job is running. Single line of what's being restored plus
+ a glowing cyan progress bar and overall percentage. */
+.restore-strip {
+ flex: 1;
+ min-width: 0;
+ display: grid;
+ grid-template-columns: 1fr auto;
+ grid-template-rows: auto auto;
+ column-gap: 14px;
+ row-gap: 6px;
+ align-items: center;
+ margin: 0 18px 0 22px;
+ animation: fade-in .22s ease-out;
+}
+.restore-strip-line {
+ grid-column: 1;
+ grid-row: 1;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: var(--fs-sm);
+ color: var(--text-1);
+ white-space: nowrap;
+ overflow: hidden;
+ min-width: 0;
+}
+.restore-strip-line > svg { color: var(--accent-hi); flex: none; filter: drop-shadow(0 0 6px var(--accent-glow)); }
+.restore-strip-action {
+ color: var(--accent-hi);
+ font-weight: 600;
+ letter-spacing: 0.01em;
+ flex: none;
+}
+.restore-strip-label {
+ color: var(--text-1);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+ flex: 1;
+}
+.restore-strip-bar {
+ grid-column: 1;
+ grid-row: 2;
+ height: 4px;
+ background: var(--hairline-strong);
+ border-radius: 999px;
+ position: relative;
+ overflow: hidden;
+}
+.restore-strip-fill {
+ position: absolute;
+ inset: 0 auto 0 0;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--accent) 0%, var(--accent-hi) 60%, var(--accent) 100%);
+ background-size: 200% 100%;
+ box-shadow: 0 0 10px var(--accent), 0 0 22px var(--accent-glow);
+ transition: width .26s cubic-bezier(.4,.7,.3,1);
+ animation: restore-shimmer 1.6s linear infinite;
+}
+.restore-strip-fill::after {
+ content: '';
+ position: absolute;
+ top: 0; right: 0; bottom: 0;
+ width: 18px;
+ background: linear-gradient(90deg, transparent, oklch(0.98 0.05 220 / 0.85));
+ filter: blur(2px);
+}
+@keyframes restore-shimmer {
+ from { background-position: 0% 50%; }
+ to { background-position: 200% 50%; }
+}
+.restore-strip-pct {
+ grid-column: 2;
+ grid-row: 1 / span 2;
+ align-self: center;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--accent-hi);
+ letter-spacing: 0.02em;
+ text-shadow: 0 0 12px var(--accent-glow);
+ font-variant-numeric: tabular-nums;
+ min-width: 38px;
+ text-align: right;
+}
+
+/* Theme toggle (segmented sun / moon pill) */
+.theme-toggle {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 0;
+ width: 56px;
+ height: 28px;
+ padding: 0;
+ border-radius: 999px;
+ border: 1px solid var(--hairline);
+ background: var(--bg-2);
+ cursor: pointer;
+ transition: background 0.18s ease, border-color 0.18s ease;
+}
+.theme-toggle:hover { border-color: var(--hairline-strong); }
+.theme-toggle-ico {
+ flex: 1;
+ display: grid;
+ place-items: center;
+ color: var(--text-3);
+ z-index: 1;
+ pointer-events: none;
+}
+.theme-toggle-thumb {
+ position: absolute;
+ top: 2px;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ background: var(--bg-0);
+ color: var(--accent-hi);
+ border: 1px solid var(--hairline-strong);
+ box-shadow: 0 1px 2px rgba(0,0,0,0.25), 0 0 12px var(--accent-glow);
+ transition: left 0.22s cubic-bezier(.4,.7,.3,1), background 0.18s ease;
+ z-index: 2;
+}
+.theme-toggle-thumb.is-light { left: 2px; }
+.theme-toggle-thumb.is-dark { left: 30px; }
+
+.screen {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--pad-x);
+}
+
+/* ── Primitives ───────────────────────────────────────────────── */
+.card {
+ background: var(--bg-1);
+ border: 1px solid var(--hairline);
+ border-radius: var(--radius-m);
+ padding: var(--pad-card);
+}
+.card.flush { padding: 0; }
+.card-hd {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 14px var(--pad-card);
+ border-bottom: 1px solid var(--hairline);
+}
+.card-hd h2 {
+ margin: 0;
+ font-size: var(--fs-h2);
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+
+/* Button */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ height: 34px;
+ padding: 0 14px;
+ border-radius: var(--radius-s);
+ border: 1px solid var(--hairline);
+ background: var(--bg-1);
+ color: var(--text-0);
+ cursor: default;
+ font-size: var(--fs-sm);
+ font-weight: 500;
+ white-space: nowrap;
+ transition: background .12s, border-color .12s, color .12s, box-shadow .12s;
+ user-select: none;
+}
+.btn:hover { background: var(--bg-2); border-color: var(--hairline-strong); }
+.btn:disabled, .btn[disabled] {
+ opacity: 0.42;
+ cursor: not-allowed;
+ pointer-events: none;
+ filter: saturate(0.4);
+ box-shadow: none;
+}
+.btn .ico { width: 14px; height: 14px; flex: none; }
+.btn.primary {
+ background: linear-gradient(180deg, oklch(0.88 0.13 220), oklch(0.78 0.14 220));
+ color: oklch(0.2 0.05 240);
+ border: 1px solid oklch(0.88 0.16 220 / 0.6);
+ box-shadow:
+ 0 0 0 1px oklch(0.92 0.1 220 / 0.4) inset,
+ 0 0 24px oklch(0.82 0.13 220 / 0.45),
+ 0 1px 0 oklch(1 0 0 / 0.4) inset;
+ font-weight: 600;
+}
+.btn.primary:hover { filter: brightness(1.05); }
+.btn.ghost { background: transparent; border-color: transparent; color: var(--text-1); }
+.btn.ghost:hover { background: var(--bg-1); color: var(--text-0); }
+.btn.icon { padding: 0; width: 34px; justify-content: center; }
+.btn.danger { color: var(--bad); }
+.btn.danger:hover { background: oklch(0.7 0.18 25 / 0.1); border-color: oklch(0.7 0.18 25 / 0.3); }
+.btn.sm { height: 28px; padding: 0 10px; font-size: var(--fs-xs); }
+.btn.sm.icon { width: 28px; padding: 0; }
+
+/* Input */
+.input {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 34px;
+ padding: 0 10px;
+ border-radius: var(--radius-s);
+ border: 1px solid var(--hairline);
+ background: var(--bg-1);
+ color: var(--text-0);
+ transition: border-color .12s, box-shadow .12s;
+}
+.input:focus-within {
+ border-color: oklch(0.82 0.13 220 / 0.5);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+}
+.input input, .input textarea {
+ flex: 1;
+ background: transparent;
+ border: 0;
+ padding: 0;
+ min-width: 0;
+}
+.input input::placeholder, .input textarea::placeholder { color: var(--text-3); }
+.input .ico { width: 14px; height: 14px; color: var(--text-2); flex: none; }
+.input.lg { height: 38px; }
+
+textarea.input { height: auto; padding: 10px; min-height: 80px; resize: vertical; }
+
+/* Select */
+.select { position: relative; }
+.select select {
+ appearance: none;
+ -webkit-appearance: none;
+ height: 34px;
+ padding: 0 28px 0 10px;
+ border-radius: var(--radius-s);
+ border: 1px solid var(--hairline);
+ background: var(--bg-1);
+ color: var(--text-0);
+ font-size: var(--fs-sm);
+}
+.select::after {
+ content: '';
+ position: absolute;
+ right: 10px; top: 50%;
+ width: 8px; height: 8px;
+ border-right: 1.5px solid var(--text-2);
+ border-bottom: 1.5px solid var(--text-2);
+ transform: translateY(-70%) rotate(45deg);
+ pointer-events: none;
+}
+
+/* Toggle (cyan style from reference) */
+.toggle {
+ width: 38px;
+ height: 22px;
+ border-radius: 999px;
+ background: var(--bg-3);
+ border: 1px solid var(--hairline);
+ position: relative;
+ cursor: default;
+ flex: none;
+ transition: background .15s, box-shadow .15s, border-color .15s;
+}
+.toggle::after {
+ content: '';
+ position: absolute;
+ top: 2px; left: 2px;
+ width: 16px; height: 16px; border-radius: 50%;
+ background: oklch(0.85 0 0);
+ box-shadow: 0 1px 2px rgba(0,0,0,0.4);
+ transition: left .15s, background .15s;
+}
+.toggle.on {
+ background: var(--accent);
+ border-color: oklch(0.88 0.16 220);
+ box-shadow: 0 0 20px var(--accent-glow);
+}
+.toggle.on::after {
+ left: 18px;
+ background: white;
+}
+
+/* Checkbox (cyan filled) */
+.cbx {
+ width: 18px; height: 18px;
+ border-radius: 5px;
+ border: 1.5px solid var(--hairline-strong);
+ background: var(--bg-1);
+ flex: none;
+ display: grid; place-items: center;
+ cursor: default;
+ transition: background .12s, border-color .12s, box-shadow .12s;
+}
+.cbx.on {
+ background: var(--accent);
+ border-color: var(--accent);
+ box-shadow: 0 0 14px var(--accent-glow);
+}
+.cbx.on::after {
+ content: '';
+ width: 5px; height: 9px;
+ border-right: 2px solid oklch(0.2 0.05 240);
+ border-bottom: 2px solid oklch(0.2 0.05 240);
+ transform: rotate(45deg) translate(-1px, -1px);
+}
+
+/* Status dot */
+.dot {
+ width: 8px; height: 8px;
+ border-radius: 50%;
+ flex: none;
+ position: relative;
+}
+.dot.good { background: var(--good); box-shadow: 0 0 8px oklch(0.78 0.16 150 / 0.6); }
+.dot.warn { background: var(--warn); box-shadow: 0 0 8px oklch(0.82 0.15 80 / 0.6); }
+.dot.bad { background: var(--bad); box-shadow: 0 0 8px oklch(0.70 0.18 25 / 0.6); }
+.dot.idle { background: var(--text-3); }
+.dot.pulse::before {
+ content: '';
+ position: absolute; inset: -3px;
+ border-radius: 50%;
+ background: inherit;
+ opacity: 0.5;
+ animation: pulse 1.6s ease-out infinite;
+}
+@keyframes pulse {
+ 0% { transform: scale(0.6); opacity: 0.5; }
+ 100% { transform: scale(2.2); opacity: 0; }
+}
+
+/* Pill / tag */
+.pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 2px 9px;
+ border-radius: 999px;
+ font-size: var(--fs-xs);
+ background: var(--bg-2);
+ color: var(--text-1);
+ border: 1px solid var(--hairline);
+ white-space: nowrap;
+}
+.pill.cyan { background: var(--accent-soft); color: var(--accent-hi); border-color: oklch(0.82 0.13 220 / 0.3); }
+.pill.good { background: oklch(0.78 0.16 150 / 0.14); color: var(--good); border-color: oklch(0.78 0.16 150 / 0.3); }
+.pill.warn { background: oklch(0.82 0.15 80 / 0.14); color: var(--warn); border-color: oklch(0.82 0.15 80 / 0.3); }
+.pill.bad { background: oklch(0.70 0.18 25 / 0.14); color: var(--bad); border-color: oklch(0.70 0.18 25 / 0.3); }
+.pill.active {
+ background: var(--accent-soft);
+ color: var(--accent-hi);
+ border-color: oklch(0.82 0.13 220 / 0.45);
+ box-shadow: 0 0 10px var(--accent-glow), inset 0 0 0 1px oklch(0.82 0.13 220 / 0.25);
+ text-shadow: 0 0 8px var(--accent-glow);
+}
+.pill.disabled {
+ background: oklch(0.72 0.17 55 / 0.14);
+ color: oklch(0.78 0.17 55);
+ border-color: oklch(0.72 0.17 55 / 0.4);
+}
+
+/* Segmented control */
+.segmented {
+ display: inline-flex;
+ background: var(--bg-1);
+ border: 1px solid var(--hairline);
+ border-radius: var(--radius-s);
+ padding: 3px;
+ gap: 2px;
+}
+.segmented button {
+ background: transparent;
+ border: 0;
+ color: var(--text-2);
+ padding: 5px 10px;
+ border-radius: 6px;
+ cursor: default;
+ font-size: var(--fs-xs);
+ font-weight: 500;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ transition: background .12s, color .12s;
+}
+.segmented button:hover { color: var(--text-0); }
+.segmented button.active {
+ background: var(--bg-3);
+ color: var(--accent-hi);
+ box-shadow: inset 0 0 0 1px var(--hairline-strong);
+}
+
+/* Toolbar */
+.toolbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px var(--pad-card);
+ border-bottom: 1px solid var(--hairline);
+}
+.toolbar .spacer { flex: 1; }
+.tbar-sep {
+ display: inline-block;
+ width: 1px;
+ height: 18px;
+ background: var(--hairline);
+ margin: 0 4px;
+ align-self: center;
+ flex: none;
+}
+.input.input-bad {
+ border-color: oklch(0.62 0.18 30 / 0.6);
+ box-shadow: 0 0 0 3px oklch(0.62 0.18 30 / 0.12);
+}
+
+/* Toast surfaced after a custom-playlist Create/Append action completes */
+.custom-toast {
+ position: fixed;
+ left: 50%;
+ bottom: 28px;
+ transform: translateX(-50%);
+ z-index: 95;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px 10px 14px;
+ background: var(--bg-1);
+ border: 1px solid var(--hairline-strong);
+ border-radius: 999px;
+ box-shadow: 0 12px 36px rgba(0,0,0,0.35), 0 0 0 1px oklch(0.82 0.13 220 / 0.25) inset, 0 0 32px var(--accent-glow);
+ color: var(--text-0);
+ font-size: var(--fs-sm);
+ animation: custom-toast-in .26s cubic-bezier(.2,.7,.2,1);
+}
+.custom-toast > svg { color: var(--accent-hi); flex: none; }
+.custom-toast-x {
+ display: inline-grid;
+ place-items: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ border: 0;
+ background: var(--bg-3);
+ color: var(--text-2);
+ cursor: pointer;
+ margin-left: 2px;
+}
+.custom-toast-x:hover { background: var(--bg-0); color: var(--text-0); }
+@keyframes custom-toast-in {
+ from { transform: translate(-50%, 12px); opacity: 0; }
+ to { transform: translate(-50%, 0); opacity: 1; }
+}
+
+/* Table */
+.tbl { width: 100%; border-collapse: collapse; }
+.tbl thead th {
+ text-align: left;
+ font-weight: 500;
+ font-size: var(--fs-xs);
+ color: var(--text-2);
+ padding: 10px var(--pad-card);
+ border-bottom: 1px solid var(--hairline);
+ background: var(--bg-1);
+ position: sticky; top: 0;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+}
+.tbl tbody tr {
+ border-bottom: 1px solid var(--hairline);
+ transition: background .1s;
+}
+.tbl tbody tr:hover { background: var(--bg-2); }
+.tbl tbody tr.selected { background: var(--accent-soft); }
+.tbl tbody td {
+ padding: 10px var(--pad-card);
+ font-size: var(--fs-sm);
+ height: var(--row-h);
+ vertical-align: middle;
+}
+
+/* Channel logo placeholder */
+.ch-logo {
+ width: 28px; height: 28px;
+ border-radius: 6px;
+ background: var(--bg-3);
+ display: grid; place-items: center;
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--text-1);
+ letter-spacing: 0.02em;
+ flex: none;
+ border: 1px solid var(--hairline);
+ overflow: hidden;
+}
+.ch-logo.lg { width: 56px; height: 56px; font-size: 14px; border-radius: 10px; }
+
+/* Channel cards grid */
+.ch-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ gap: 12px;
+ padding: var(--pad-card);
+}
+.ch-card {
+ background: var(--bg-2);
+ border: 1px solid var(--hairline);
+ border-radius: var(--radius-m);
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ cursor: default;
+ transition: border-color .12s, background .12s, transform .12s;
+ position: relative;
+}
+.ch-card:hover { border-color: var(--hairline-strong); background: var(--bg-3); }
+.ch-card.selected { border-color: oklch(0.82 0.13 220 / 0.55); box-shadow: 0 0 0 1px oklch(0.82 0.13 220 / 0.3), 0 0 28px var(--accent-glow); }
+.ch-card .top { display: flex; align-items: flex-start; gap: 10px; }
+.ch-card .name { font-weight: 600; font-size: var(--fs-sm); line-height: 1.25; }
+.ch-card .meta { color: var(--text-2); font-size: var(--fs-xs); }
+.ch-card .row { display: flex; align-items: center; justify-content: space-between; margin-top: auto; }
+.ch-card .cbx-pos { position: absolute; top: 10px; right: 10px; opacity: 0; transition: opacity .12s; }
+.ch-card:hover .cbx-pos, .ch-card.selected .cbx-pos { opacity: 1; }
+
+/* Stats row */
+.stats {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: var(--gap);
+ margin-bottom: var(--gap);
+}
+.stat { padding: var(--pad-card); }
+.stat .lbl { font-size: var(--fs-xs); color: var(--text-2); text-transform: uppercase; letter-spacing: 0.06em; }
+.stat .val { font-size: 28px; font-weight: 600; letter-spacing: -0.02em; margin-top: 8px; font-variant-numeric: tabular-nums; }
+.stat .delta { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-xs); margin-top: 6px; color: var(--good); }
+.stat .delta.bad { color: var(--bad); }
+
+/* Source row (playlists / EPG list) */
+.src-row {
+ display: grid;
+ grid-template-columns: 40px 1fr auto auto auto auto;
+ align-items: center;
+ gap: 14px;
+ padding: 14px var(--pad-card);
+ border-bottom: 1px solid var(--hairline);
+ transition: background .1s;
+ cursor: default;
+}
+.src-row:last-child { border-bottom: 0; }
+.src-row:hover { background: var(--bg-2); }
+.src-row .src-ico {
+ width: 40px; height: 40px;
+ border-radius: 10px;
+ background: var(--bg-3);
+ display: grid; place-items: center;
+ color: var(--accent);
+ border: 1px solid var(--hairline);
+}
+.src-row .src-name { font-weight: 600; font-size: var(--fs-base); display: flex; align-items: center; gap: 8px; }
+.src-row .src-url { color: var(--text-2); font-size: var(--fs-xs); font-family: 'JetBrains Mono', ui-monospace, monospace; margin-top: 3px; }
+.src-row .stat-mini { font-size: var(--fs-xs); color: var(--text-2); text-align: right; min-width: 60px; }
+.src-row .stat-mini b { color: var(--text-0); font-size: var(--fs-base); font-weight: 600; display: block; }
+
+/* EPG timeline */
+.epg {
+ display: grid;
+ grid-template-rows: 36px 1fr;
+ height: 100%;
+}
+.epg-head {
+ display: grid;
+ grid-template-columns: 200px 1fr;
+ border-bottom: 1px solid var(--hairline-strong);
+ position: sticky; top: 0; z-index: 4;
+ background: var(--bg-1);
+}
+.epg-head .head-l { border-right: 1px solid var(--hairline); display: flex; align-items: center; padding: 0 14px; font-size: var(--fs-xs); color: var(--text-2); text-transform: uppercase; letter-spacing: 0.06em; }
+.epg-head .head-r { display: flex; position: relative; }
+.epg-time {
+ flex: none;
+ font-size: var(--fs-xs);
+ color: var(--text-2);
+ padding: 0 8px;
+ border-right: 1px solid var(--hairline);
+ display: flex;
+ align-items: center;
+}
+.epg-body { overflow: auto; }
+.epg-row {
+ display: grid;
+ grid-template-columns: 200px 1fr;
+ border-bottom: 1px solid var(--hairline);
+ min-height: 76px;
+}
+.epg-row .ch {
+ border-right: 1px solid var(--hairline);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0 14px;
+ background: var(--bg-1);
+ position: sticky; left: 0; z-index: 2;
+}
+.epg-row .ch .nm { font-size: var(--fs-sm); font-weight: 500; }
+.epg-row .ch .num { color: var(--text-3); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
+.epg-progs { position: relative; }
+.epg-prog {
+ position: absolute;
+ top: 8px; bottom: 8px;
+ background: var(--bg-2);
+ border: 1px solid var(--hairline);
+ border-radius: 8px;
+ padding: 8px 11px;
+ font-size: var(--fs-xs);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 3px;
+ cursor: default;
+ transition: background .1s, border-color .1s, transform .1s;
+}
+.epg-prog:hover { background: var(--bg-3); border-color: var(--hairline-strong); }
+.epg-prog.live {
+ background: var(--accent-soft);
+ border-color: oklch(0.82 0.13 220 / 0.4);
+ color: var(--accent-hi);
+}
+.epg-prog .t { font-weight: 600; color: var(--text-0); line-height: 1.25; }
+.epg-prog.live .t { color: var(--accent-hi); }
+.epg-prog .sub { color: var(--text-2); font-size: 10.5px; line-height: 1.35; }
+.now-line {
+ position: absolute;
+ top: 0; bottom: 0;
+ width: 2px;
+ background: var(--accent);
+ box-shadow: 0 0 12px var(--accent);
+ z-index: 3;
+ pointer-events: none;
+}
+.now-line::before {
+ content: '';
+ position: absolute;
+ top: -3px; left: -4px;
+ width: 10px; height: 10px;
+ background: var(--accent);
+ border-radius: 50%;
+ box-shadow: 0 0 12px var(--accent);
+}
+
+/* Drop zone */
+.dropzone {
+ border: 2px dashed var(--hairline-strong);
+ border-radius: var(--radius-l);
+ padding: 56px 24px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 14px;
+ text-align: center;
+ background: var(--bg-1);
+ cursor: default;
+ transition: border-color .15s, background .15s;
+}
+.dropzone:hover, .dropzone.over {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+}
+.dropzone .icon-circle {
+ width: 56px; height: 56px;
+ border-radius: 50%;
+ background: var(--accent-soft);
+ display: grid; place-items: center;
+ color: var(--accent);
+}
+.dropzone h3 { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -0.01em; }
+.dropzone p { margin: 0; color: var(--text-2); font-size: var(--fs-sm); }
+
+/* Player */
+.player {
+ background: black;
+ border: 1px solid var(--hairline);
+ border-radius: var(--radius-m);
+ aspect-ratio: 16 / 9;
+ position: relative;
+ overflow: hidden;
+}
+.player .stripes {
+ position: absolute; inset: 0;
+ background:
+ repeating-linear-gradient(45deg, oklch(0.22 0.01 240) 0 12px, oklch(0.18 0.01 240) 12px 24px);
+ opacity: 0.5;
+}
+.player .label {
+ position: absolute; left: 12px; top: 10px;
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+ font-size: 11px;
+ color: var(--text-2);
+ background: rgba(0,0,0,0.5);
+ padding: 3px 8px;
+ border-radius: 6px;
+ border: 1px solid var(--hairline);
+}
+.player .play {
+ position: absolute; inset: 0;
+ display: grid; place-items: center;
+}
+.player .play-btn {
+ width: 64px; height: 64px;
+ border-radius: 50%;
+ background: var(--accent);
+ display: grid; place-items: center;
+ box-shadow: 0 0 32px var(--accent-glow);
+ color: oklch(0.18 0.05 240);
+}
+.player .controls {
+ position: absolute;
+ bottom: 0; left: 0; right: 0;
+ height: 44px;
+ background: linear-gradient(180deg, transparent, rgba(0,0,0,0.7));
+ display: flex;
+ align-items: center;
+ padding: 0 14px;
+ gap: 10px;
+ color: var(--text-1);
+}
+.player .controls .track {
+ flex: 1;
+ height: 3px;
+ background: var(--hairline-strong);
+ border-radius: 999px;
+ position: relative;
+}
+.player .controls .track::after {
+ content: '';
+ position: absolute;
+ left: 0; top: 0; bottom: 0;
+ width: 32%;
+ background: var(--accent);
+ border-radius: 999px;
+ box-shadow: 0 0 12px var(--accent);
+}
+
+/* Section header */
+.section-title {
+ font-size: var(--fs-h2);
+ font-weight: 600;
+ margin: 0 0 var(--gap);
+ letter-spacing: -0.01em;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+/* Mapping */
+.map-grid {
+ display: grid;
+ grid-template-columns: 1fr 40px 1fr;
+ gap: 14px;
+ align-items: stretch;
+}
+.map-col {
+ background: var(--bg-1);
+ border: 1px solid var(--hairline);
+ border-radius: var(--radius-m);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ min-height: 480px;
+}
+.map-col h3 {
+ margin: 0;
+ font-size: var(--fs-sm);
+ font-weight: 600;
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--hairline);
+ color: var(--text-1);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.map-list { overflow-y: auto; flex: 1; }
+.map-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--hairline);
+ cursor: default;
+ transition: background .1s;
+}
+.map-item:hover { background: var(--bg-2); }
+.map-item.selected { background: var(--accent-soft); }
+.map-item.matched { opacity: 0.55; }
+.map-item .nm { font-size: var(--fs-sm); font-weight: 500; flex: 1; }
+.map-item .id { font-size: var(--fs-xs); color: var(--text-2); font-family: 'JetBrains Mono', ui-monospace, monospace; }
+.map-link {
+ display: grid;
+ place-items: center;
+ color: var(--text-3);
+}
+.match-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--hairline);
+ font-size: var(--fs-sm);
+}
+.match-row .arr { color: var(--accent); }
+
+/* Activity feed */
+.act {
+ display: flex;
+ gap: 12px;
+ padding: 12px var(--pad-card);
+ border-bottom: 1px solid var(--hairline);
+ font-size: var(--fs-sm);
+}
+.act:last-child { border-bottom: 0; }
+.act .when { color: var(--text-3); font-size: var(--fs-xs); margin-top: 2px; font-variant-numeric: tabular-nums; }
+.act .ico-w {
+ width: 28px; height: 28px;
+ border-radius: 50%;
+ background: var(--bg-2);
+ border: 1px solid var(--hairline);
+ display: grid; place-items: center;
+ color: var(--text-1);
+ flex: none;
+}
+.act b { font-weight: 600; }
+
+/* Sync grid (Settings → Syncing card) — every row shares the same 5 columns */
+.sync-grid {
+ display: flex;
+ flex-direction: column;
+ margin-top: 4px;
+}
+.sync-row {
+ display: grid;
+ grid-template-columns: 28px 1fr 180px 130px 44px;
+ align-items: center;
+ column-gap: 14px;
+ padding: 12px 0;
+ border-bottom: 1px solid var(--hairline);
+ transition: background .1s, opacity .12s;
+}
+.sync-row.sync-row-last,
+.sync-row:last-child { border-bottom: 0; }
+.sync-row.disabled { opacity: 0.5; }
+.sync-row-lvl .sched-ico { display: none; }
+.sync-row-lvl .sync-meta { grid-column: 1 / span 2; }
+.sync-row-sched:hover { background: var(--bg-2); margin: 0 -10px; padding-left: 10px; padding-right: 10px; }
+.sync-meta { min-width: 0; }
+.sync-lbl { font-size: var(--fs-base); font-weight: 500; color: var(--text-0); }
+.sync-hint { font-size: var(--fs-xs); color: var(--text-2); margin-top: 3px; }
+.sync-col-sched, .sync-col-cron, .sync-col-toggle { display: flex; align-items: center; }
+.sync-col-toggle { justify-content: flex-end; }
+.sync-col-cron { justify-content: flex-end; }
+
+/* Section header rows span all 5 columns */
+.sync-section-hd {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 14px 0 10px;
+ font-size: var(--fs-sm);
+ font-weight: 600;
+ color: var(--text-1);
+ border-bottom: 1px solid var(--hairline);
+ margin-top: 4px;
+}
+.sync-section-hd > .ico:first-child { color: var(--text-1); }
+.sync-section-hd.collapsible { cursor: default; user-select: none; }
+.sync-section-hd.collapsible:hover { color: var(--text-0); }
+.sync-section-hd .chev { color: var(--text-2); transition: transform .18s ease; }
+.sync-section-hd.collapsible.open .chev { transform: rotate(90deg); }
+
+/* Endpoint fields (Settings → General → Hosting endpoints) */
+.endpoint-field {
+ display: grid;
+ grid-template-columns: 160px 1fr auto;
+ align-items: center;
+ gap: 10px;
+}
+.endpoint-lbl {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: var(--fs-sm);
+ font-weight: 500;
+ color: var(--text-1);
+}
+.endpoint-ico {
+ width: 26px; height: 26px;
+ border-radius: 7px;
+ background: var(--bg-2);
+ border: 1px solid var(--hairline);
+ display: grid; place-items: center;
+}
+
+.sched-ico {
+ width: 28px; height: 28px;
+ border-radius: 7px;
+ background: var(--bg-3);
+ border: 1px solid var(--hairline);
+ display: grid; place-items: center;
+ color: var(--text-1);
+}
+.sched-ico.is-epg { color: var(--good); }
+.sched-ico.builtin {
+ background: linear-gradient(135deg, oklch(0.28 0.05 220), oklch(0.22 0.06 280));
+ color: var(--accent-hi);
+ border-color: oklch(0.82 0.13 220 / 0.3);
+}
+.sched-ico.builtin.epg-builtin {
+ background: linear-gradient(135deg, oklch(0.28 0.08 150), oklch(0.22 0.08 150));
+ color: var(--good);
+ border-color: oklch(0.78 0.16 150 / 0.35);
+}
+
+.cron-chip {
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+ font-size: 10.5px;
+ padding: 4px 8px;
+ border-radius: 6px;
+ background: var(--bg-1);
+ border: 1px solid var(--hairline);
+ color: var(--accent-hi);
+ cursor: default;
+ letter-spacing: 0.02em;
+ transition: border-color .12s, background .12s;
+ white-space: nowrap;
+}
+.cron-chip:hover { border-color: var(--accent); background: var(--accent-soft); }
+
+/* No chevron on the schedule selectors — keeps row aligned with the cron chip */
+.sync-col-sched .select::after { display: none; }
+.sync-col-sched .select select { padding-right: 12px; width: 100%; }
+
+/* Sidebar foot stack + Logs button */
+.sidebar-foot-stack { margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
+.logs-btn {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 12px;
+ border-radius: var(--radius-s);
+ background: var(--bg-1);
+ border: 1px solid var(--hairline);
+ color: var(--text-1);
+ font-size: var(--fs-sm);
+ cursor: default;
+ transition: background .12s, border-color .12s, color .12s;
+}
+.logs-btn:hover { background: var(--bg-2); border-color: var(--hairline-strong); color: var(--text-0); }
+.logs-btn-ico {
+ position: relative;
+ width: 22px; height: 22px;
+ border-radius: 6px;
+ background: var(--bg-2);
+ border: 1px solid var(--hairline);
+ display: grid; place-items: center;
+ color: var(--text-1);
+}
+.logs-btn-ico .dot { position: absolute; top: -1px; right: -1px; }
+
+/* Realtime logs bottom drawer */
+.logs-drawer-wrap { position: fixed; inset: 0; z-index: 88; pointer-events: none; }
+.logs-drawer-backdrop {
+ position: absolute; inset: 0;
+ pointer-events: auto;
+ animation: fade-in .18s ease-out;
+}
+.logs-drawer {
+ position: absolute;
+ left: 0; right: 0; bottom: 0;
+ height: 46vh;
+ min-height: 280px;
+ pointer-events: auto;
+ display: flex;
+ flex-direction: column;
+ border-bottom: 0;
+ border-left: 0;
+ border-right: 0;
+ animation: slidein-bottom .26s cubic-bezier(.2,.7,.2,1);
+}
+@keyframes slidein-bottom { from { transform: translateY(30px); opacity: 0; } to { transform: none; opacity: 1; } }
+
+.logs-hd {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--hairline);
+ background: linear-gradient(180deg, oklch(0 0 0 / 0.18), oklch(0 0 0 / 0));
+}
+[data-theme="light"] .logs-hd { background: linear-gradient(180deg, oklch(1 0 0 / 0.4), oklch(1 0 0 / 0)); }
+.logs-hd .row { flex: 1; }
+
+.logs-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px 0;
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+ font-size: 11.5px;
+ line-height: 1.55;
+}
+
+.log-line {
+ display: grid;
+ grid-template-columns: 90px 60px 60px 1fr;
+ gap: 12px;
+ padding: 3px 16px;
+ color: var(--text-1);
+ border-left: 2px solid transparent;
+}
+.log-line:hover { background: oklch(1 0 0 / 0.025); }
+[data-theme="light"] .log-line:hover { background: oklch(0 0 0 / 0.03); }
+.log-ts { color: var(--text-3); }
+.log-lvl { font-weight: 600; font-size: 10.5px; letter-spacing: 0.04em; padding-top: 1px; }
+.log-src { color: var(--text-2); }
+.log-msg { color: var(--text-1); word-break: break-word; }
+
+.log-line.log-warn { border-left-color: oklch(0.82 0.15 80 / 0.5); background: oklch(0.82 0.15 80 / 0.04); }
+.log-line.log-error { border-left-color: oklch(0.7 0.18 25 / 0.6); background: oklch(0.7 0.18 25 / 0.06); }
+.log-line.log-error .log-msg { color: oklch(0.86 0.12 25); }
+.log-line.log-ok { border-left-color: oklch(0.78 0.16 150 / 0.4); }
+.log-line.log-debug { opacity: 0.7; }
+
+/* Mapping history rows */
+.hist-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ padding: 12px var(--pad-card);
+ border-bottom: 1px solid var(--hairline);
+}
+.hist-row:last-child { border-bottom: 0; }
+.hist-row:hover { background: var(--bg-2); }
+.hist-ico {
+ width: 30px; height: 30px;
+ border-radius: 8px;
+ background: var(--bg-2);
+ border: 1px solid var(--hairline);
+ display: grid; place-items: center;
+ flex: none;
+ margin-top: 2px;
+}
+
+/* History / Metrics screen */
+.hm-grid {
+ display: grid;
+ grid-template-columns: 1.4fr 1fr;
+ gap: 14px;
+ min-height: 0;
+}
+.hm-list { overflow: hidden; }
+.hm-detail { overflow-y: auto; max-height: 720px; }
+
+.qoe-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 2px 9px 2px 7px;
+ border-radius: 999px;
+ font-size: 11.5px;
+ font-weight: 600;
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+ border: 1px solid var(--hairline);
+}
+.qoe-pill[data-health="good"] { background: oklch(0.78 0.16 150 / 0.14); color: var(--good); border-color: oklch(0.78 0.16 150 / 0.3); }
+.qoe-pill[data-health="good"] .dot { background: var(--good); box-shadow: 0 0 6px var(--good); }
+.qoe-pill[data-health="warn"] { background: oklch(0.82 0.15 80 / 0.14); color: var(--warn); border-color: oklch(0.82 0.15 80 / 0.3); }
+.qoe-pill[data-health="warn"] .dot { background: var(--warn); box-shadow: 0 0 6px var(--warn); }
+.qoe-pill[data-health="bad"] { background: oklch(0.70 0.18 25 / 0.14); color: var(--bad); border-color: oklch(0.70 0.18 25 / 0.3); }
+.qoe-pill[data-health="bad"] .dot { background: var(--bad); box-shadow: 0 0 6px var(--bad); }
+.qoe-pill .dot { width: 6px; height: 6px; border-radius: 50%; }
+
+/* Buffer bar chart */
+.buf-bars {
+ display: flex;
+ align-items: flex-end;
+ gap: 3px;
+ height: 110px;
+ padding: 4px 0;
+}
+.buf-bar-wrap { flex: 1; height: 100%; display: flex; align-items: flex-end; }
+.buf-bar {
+ width: 100%;
+ border-radius: 3px 3px 0 0;
+ min-height: 2px;
+}
+
+/* Buffering timeline */
+.buf-timeline {
+ position: relative;
+ height: 32px;
+ background: var(--bg-2);
+ border-radius: 6px;
+ border: 1px solid var(--hairline);
+ overflow: hidden;
+}
+.buf-timeline-empty {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ color: var(--good);
+ font-size: 11px;
+}
+.buf-event {
+ position: absolute;
+ top: 4px; bottom: 4px;
+ background: var(--bad);
+ border-radius: 3px;
+ box-shadow: 0 0 8px oklch(0.7 0.18 25 / 0.6);
+ cursor: default;
+}
+
+.logs-scroll-btn {
+ position: absolute;
+ left: 50%;
+ bottom: 14px;
+ transform: translateX(-50%);
+ background: var(--accent);
+ color: oklch(0.18 0.05 240);
+ border: 0;
+ padding: 6px 12px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 600;
+ cursor: default;
+ box-shadow: 0 4px 18px var(--accent-glow), 0 0 0 1px oklch(0.92 0.1 220 / 0.4) inset;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ z-index: 5;
+}
+
+/* Channel drawer */
+.drawer-wrap { position: fixed; inset: 0; z-index: 80; pointer-events: none; }
+.drawer-backdrop { position: absolute; inset: 0; pointer-events: auto; animation: fade-in .2s ease-out; }
+.drawer-panel {
+ position: absolute;
+ top: 0; right: 0; bottom: 0;
+ width: 440px;
+ pointer-events: auto;
+ display: flex;
+ flex-direction: column;
+ border-radius: 0;
+ border-right: 0;
+ border-top: 0;
+ border-bottom: 0;
+ animation: slidein-right .26s cubic-bezier(.2,.7,.2,1);
+}
+.drawer-hd {
+ padding: 16px 22px;
+ border-bottom: 1px solid var(--hairline);
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ background: linear-gradient(180deg, oklch(0 0 0 / 0.15), oklch(0 0 0 / 0));
+ flex: none;
+}
+[data-theme="light"] .drawer-hd { background: linear-gradient(180deg, oklch(1 0 0 / 0.4), oklch(1 0 0 / 0)); }
+.drawer-body {
+ padding: 22px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ overflow-y: auto;
+ flex: 1;
+}
+
+/* \u2500\u2500 Glass panel surface (shared by side-panels + modals) \u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
+.glass {
+ background:
+ linear-gradient(180deg, oklch(1 0 0 / 0.04), oklch(1 0 0 / 0) 40%),
+ oklch(0.20 0.006 240 / 0.72);
+ backdrop-filter: blur(28px) saturate(140%);
+ -webkit-backdrop-filter: blur(28px) saturate(140%);
+ border: 1px solid oklch(1 0 0 / 0.08);
+ box-shadow:
+ inset 0 1px 0 oklch(1 0 0 / 0.06),
+ inset 0 0 0 1px oklch(0.82 0.13 220 / 0.05),
+ -1px 0 0 oklch(0.82 0.13 220 / 0.08),
+ 0 30px 80px rgba(0,0,0,0.5),
+ 0 0 80px oklch(0.82 0.13 220 / 0.10);
+}
+[data-theme="light"] .glass {
+ background:
+ linear-gradient(180deg, oklch(1 0 0 / 0.7), oklch(1 0 0 / 0.5) 40%),
+ oklch(0.97 0.004 240 / 0.7);
+ border-color: oklch(0 0 0 / 0.08);
+ box-shadow:
+ inset 0 1px 0 oklch(1 0 0 / 0.6),
+ 0 30px 80px oklch(0.25 0.05 240 / 0.18),
+ 0 0 80px oklch(0.82 0.13 220 / 0.12);
+}
+.glass-bg {
+ background: oklch(0.1 0.005 240 / 0.45);
+ backdrop-filter: blur(8px) saturate(120%);
+ -webkit-backdrop-filter: blur(8px) saturate(120%);
+}
+[data-theme="light"] .glass-bg {
+ background: oklch(0.6 0.01 240 / 0.32);
+}
+
+/* Modal */
+.modal-bg {
+ position: fixed; inset: 0;
+ background: oklch(0.1 0.005 240 / 0.55);
+ backdrop-filter: blur(10px) saturate(120%);
+ -webkit-backdrop-filter: blur(10px) saturate(120%);
+ z-index: 90;
+ display: grid; place-items: center;
+ animation: fade-in .18s ease-out;
+}
+[data-theme="light"] .modal-bg {
+ background: oklch(0.6 0.01 240 / 0.36);
+}
+.modal {
+ width: 560px;
+ background:
+ linear-gradient(180deg, oklch(1 0 0 / 0.04), oklch(1 0 0 / 0) 40%),
+ oklch(0.20 0.006 240 / 0.78);
+ backdrop-filter: blur(32px) saturate(140%);
+ -webkit-backdrop-filter: blur(32px) saturate(140%);
+ border: 1px solid oklch(1 0 0 / 0.10);
+ border-radius: var(--radius-l);
+ box-shadow:
+ inset 0 1px 0 oklch(1 0 0 / 0.07),
+ 0 30px 80px rgba(0,0,0,0.55),
+ 0 0 100px oklch(0.82 0.13 220 / 0.18);
+ overflow: hidden;
+ animation: modal-in .24s cubic-bezier(.2,.7,.2,1);
+}
+[data-theme="light"] .modal {
+ background:
+ linear-gradient(180deg, oklch(1 0 0 / 0.75), oklch(1 0 0 / 0.55) 40%),
+ oklch(0.97 0.004 240 / 0.7);
+ border-color: oklch(0 0 0 / 0.08);
+ box-shadow:
+ inset 0 1px 0 oklch(1 0 0 / 0.7),
+ 0 30px 80px oklch(0.25 0.05 240 / 0.22),
+ 0 0 100px oklch(0.82 0.13 220 / 0.2);
+}
+.modal-hd {
+ padding: 18px 22px;
+ border-bottom: 1px solid var(--hairline);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ background: linear-gradient(180deg, oklch(1 0 0 / 0.02), transparent);
+}
+.modal-hd h2 { margin: 0; font-size: 16px; font-weight: 600; }
+.modal-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
+.modal-ft {
+ padding: 14px 22px;
+ border-top: 1px solid var(--hairline);
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ background: oklch(0 0 0 / 0.18);
+}
+[data-theme="light"] .modal-ft { background: oklch(0 0 0 / 0.03); }
+@keyframes modal-in {
+ from { transform: translateY(12px) scale(0.98); opacity: 0; }
+ to { transform: none; opacity: 1; }
+}
+.field-lbl { font-size: var(--fs-xs); color: var(--text-2); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
+
+/* Form */
+.form-row { display: flex; flex-direction: column; }
+.form-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
+
+/* Empty state */
+.empty {
+ padding: 60px 20px;
+ text-align: center;
+ color: var(--text-2);
+}
+.empty h3 { color: var(--text-0); margin: 0 0 6px; font-size: 16px; font-weight: 600; }
+
+/* Tweaks panel surface override (dark) */
+[data-theme="dark"] .twk-panel {
+ background: rgba(28, 30, 36, 0.85) !important;
+ color: var(--text-0) !important;
+ border-color: rgba(255,255,255,0.08) !important;
+ box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 60px oklch(0.82 0.13 220 / 0.05) !important;
+}
+[data-theme="dark"] .twk-panel * { color: inherit; }
+[data-theme="dark"] .twk-lbl { color: var(--text-1) !important; }
+[data-theme="dark"] .twk-val { color: var(--text-3) !important; }
+
+/* Active Streams */
+.streams-grid {
+ display: grid;
+ grid-template-columns: 380px 1fr;
+ gap: 14px;
+ height: 100%;
+ min-height: 0;
+}
+.streams-list {
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-1);
+ border: 1px solid var(--hairline);
+ border-radius: var(--radius-m);
+ overflow: hidden;
+ min-height: 0;
+}
+.streams-list .body { overflow-y: auto; flex: 1; }
+.stream-item {
+ padding: 12px 14px;
+ display: grid;
+ grid-template-columns: 36px 1fr auto;
+ align-items: center;
+ gap: 12px;
+ border-bottom: 1px solid var(--hairline);
+ cursor: default;
+ transition: background .1s;
+ border-left: 2px solid transparent;
+}
+.stream-item:hover { background: var(--bg-2); }
+.stream-item.selected {
+ background: var(--accent-soft);
+ border-left-color: var(--accent);
+}
+.stream-item .nm { font-weight: 500; font-size: var(--fs-sm); display: flex; align-items: center; gap: 8px; }
+.stream-item .meta { font-size: var(--fs-xs); color: var(--text-2); margin-top: 3px; display: flex; align-items: center; gap: 8px; font-variant-numeric: tabular-nums; }
+.stream-item .meta .mono { color: var(--text-1); }
+.stream-item .viewer {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+.stream-item .viewer b { font-size: var(--fs-base); font-weight: 600; color: var(--text-0); display: block; }
+.stream-item .viewer span { font-size: 10px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; }
+
+.stream-detail {
+ background: var(--bg-1);
+ border: 1px solid var(--hairline);
+ border-radius: var(--radius-m);
+ overflow-y: auto;
+ min-height: 0;
+}
+
+.metric-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 10px;
+}
+.metric {
+ padding: 12px 14px;
+ background: var(--bg-2);
+ border: 1px solid var(--hairline);
+ border-radius: 10px;
+}
+.metric .lbl { font-size: 10px; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.06em; }
+.metric .val { font-size: 19px; font-weight: 600; letter-spacing: -0.01em; margin-top: 6px; font-variant-numeric: tabular-nums; }
+.metric .sub { font-size: var(--fs-xs); color: var(--text-3); margin-top: 2px; }
+
+.kv-list {
+ display: grid;
+ grid-template-columns: 130px 1fr;
+ row-gap: 8px;
+ column-gap: 14px;
+ font-size: var(--fs-sm);
+}
+.kv-list .k { color: var(--text-2); }
+.kv-list .v { color: var(--text-0); }
+
+.live-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 10px 3px 8px;
+ border-radius: 999px;
+ background: oklch(0.70 0.18 25 / 0.15);
+ color: oklch(0.78 0.18 25);
+ border: 1px solid oklch(0.70 0.18 25 / 0.35);
+ font-size: 10.5px;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+.live-pill .dot { background: oklch(0.7 0.18 25); box-shadow: 0 0 8px oklch(0.7 0.18 25); }
+
+/* Stream view slide-over (1/2 screen) */
+.stream-view-bg {
+ position: fixed;
+ inset: 0;
+ z-index: 85;
+ background: oklch(0.1 0.005 240 / 0.45);
+ backdrop-filter: blur(8px) saturate(120%);
+ -webkit-backdrop-filter: blur(8px) saturate(120%);
+ animation: fade-in .2s ease-out;
+}
+[data-theme="light"] .stream-view-bg { background: oklch(0.6 0.01 240 / 0.32); }
+.stream-view {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 50vw;
+ min-width: 560px;
+ background:
+ linear-gradient(180deg, oklch(1 0 0 / 0.04), oklch(1 0 0 / 0) 40%),
+ oklch(0.20 0.006 240 / 0.74);
+ backdrop-filter: blur(30px) saturate(140%);
+ -webkit-backdrop-filter: blur(30px) saturate(140%);
+ border-left: 1px solid oklch(1 0 0 / 0.10);
+ box-shadow:
+ inset 1px 0 0 oklch(1 0 0 / 0.06),
+ -1px 0 0 oklch(0.82 0.13 220 / 0.18),
+ -30px 0 80px rgba(0,0,0,0.55),
+ -40px 0 120px oklch(0.82 0.13 220 / 0.12);
+ display: flex;
+ flex-direction: column;
+ animation: slidein-right .26s cubic-bezier(.2,.7,.2,1);
+}
+[data-theme="light"] .stream-view {
+ background:
+ linear-gradient(180deg, oklch(1 0 0 / 0.75), oklch(1 0 0 / 0.55) 40%),
+ oklch(0.97 0.004 240 / 0.7);
+ border-left-color: oklch(0 0 0 / 0.08);
+ box-shadow:
+ inset 1px 0 0 oklch(1 0 0 / 0.7),
+ -1px 0 0 oklch(0.82 0.13 220 / 0.22),
+ -30px 0 80px oklch(0.25 0.05 240 / 0.22),
+ -40px 0 120px oklch(0.82 0.13 220 / 0.15);
+}
+.stream-view-hd {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px 22px;
+ border-bottom: 1px solid var(--hairline);
+ background: linear-gradient(180deg, oklch(0 0 0 / 0.15), oklch(0 0 0 / 0));
+ flex: none;
+}
+[data-theme="light"] .stream-view-hd {
+ background: linear-gradient(180deg, oklch(1 0 0 / 0.4), oklch(1 0 0 / 0));
+}
+.stream-view-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 18px 22px 28px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+@keyframes slidein-right { from { transform: translateX(40px); opacity: 0; } to { transform: none; opacity: 1; } }
+@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
+
+.player-ctrl {
+ background: transparent;
+ border: 0;
+ color: var(--text-1);
+ width: 28px;
+ height: 28px;
+ border-radius: 6px;
+ display: grid;
+ place-items: center;
+ cursor: default;
+ transition: background .12s, color .12s;
+}
+.player-ctrl:hover { background: rgba(255,255,255,0.08); color: white; }
+
+/* Built-in / system source badge */
+.pill.system {
+ background: linear-gradient(135deg, oklch(0.82 0.13 220 / 0.22), oklch(0.6 0.18 280 / 0.22));
+ color: var(--accent-hi);
+ border-color: oklch(0.82 0.13 220 / 0.4);
+ font-weight: 600;
+ letter-spacing: 0.04em;
+}
+.src-row .src-ico.builtin {
+ background: linear-gradient(135deg, oklch(0.28 0.05 220), oklch(0.22 0.06 280));
+ color: var(--accent-hi);
+ border-color: oklch(0.82 0.13 220 / 0.3);
+ box-shadow: inset 0 0 0 1px oklch(0.82 0.13 220 / 0.15), 0 0 18px oklch(0.82 0.13 220 / 0.15);
+}
+.src-row .src-ico.builtin.epg-builtin,
+.src-ico.builtin.epg-builtin {
+ background: linear-gradient(135deg, oklch(0.28 0.08 150), oklch(0.22 0.08 150));
+ color: var(--good);
+ border-color: oklch(0.78 0.16 150 / 0.35);
+ box-shadow: inset 0 0 0 1px oklch(0.78 0.16 150 / 0.2), 0 0 18px oklch(0.78 0.16 150 / 0.2);
+}
+
+/* Utility */
+.row { display: flex; align-items: center; gap: 10px; }
+.col { display: flex; flex-direction: column; gap: 10px; }
+.muted { color: var(--text-2); }
+.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
+.divider { height: 1px; background: var(--hairline); margin: 8px 0; }
+.spacer { flex: 1; }
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 00000000..70578ecf
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "preserve",
+ "strict": false,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true,
+ "esModuleInterop": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo
new file mode 100644
index 00000000..85e3bf08
--- /dev/null
+++ b/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./src/data.ts","./src/main.ts","./src/router.ts","./src/shims-vue.d.ts","./src/composables/bus.ts","./src/composables/usesettings.ts","./src/composables/usetweaks.ts","./src/app.vue","./src/components/addsourcemodal.vue","./src/components/btn.vue","./src/components/channelbulkdrawer.vue","./src/components/channeldrawer.vue","./src/components/channellogo.vue","./src/components/checkbox.vue","./src/components/endpointfield.vue","./src/components/hlsplayer.vue","./src/components/icon.vue","./src/components/logsdrawer.vue","./src/components/pill.vue","./src/components/playliststatusdrawer.vue","./src/components/searchinput.vue","./src/components/segmented.vue","./src/components/settingsrow.vue","./src/components/sparkline.vue","./src/components/stat.vue","./src/components/statusdot.vue","./src/components/toggle.vue","./src/components/tweakradio.vue","./src/components/tweaksection.vue","./src/components/tweakspanel.vue","./src/screens/activestreamsscreen.vue","./src/screens/dashboardscreen.vue","./src/screens/epgdetailscreen.vue","./src/screens/epgsourcesscreen.vue","./src/screens/historymetricsscreen.vue","./src/screens/importscreen.vue","./src/screens/mappingscreen.vue","./src/screens/playlistdetailscreen.vue","./src/screens/playlistsscreen.vue","./src/screens/settingsscreen.vue"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 00000000..6ac7a7d0
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from 'vite';
+import vue from '@vitejs/plugin-vue';
+
+export default defineConfig({
+ plugins: [vue()],
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:3000',
+ changeOrigin: true,
+ },
+ },
+ },
+});