mirror of
https://github.com/TheBinaryNinja/tvapp2.git
synced 2026-06-12 08:45:42 -04:00
initial push from external dev branches
This commit is contained in:
116
server/dist/sources/adapters/dulo.js
vendored
Normal file
116
server/dist/sources/adapters/dulo.js
vendored
Normal file
@@ -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
|
||||
1
server/dist/sources/adapters/dulo.js.map
vendored
Normal file
1
server/dist/sources/adapters/dulo.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"dulo.js","sourceRoot":"","sources":["../../../src/sources/adapters/dulo.ts"],"names":[],"mappings":"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"}
|
||||
39
server/dist/sources/core/buildSource.js
vendored
Normal file
39
server/dist/sources/core/buildSource.js
vendored
Normal file
@@ -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
|
||||
1
server/dist/sources/core/buildSource.js.map
vendored
Normal file
1
server/dist/sources/core/buildSource.js.map
vendored
Normal file
@@ -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"}
|
||||
18
server/dist/sources/core/logger.js
vendored
Normal file
18
server/dist/sources/core/logger.js
vendored
Normal file
@@ -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
|
||||
1
server/dist/sources/core/logger.js.map
vendored
Normal file
1
server/dist/sources/core/logger.js.map
vendored
Normal file
@@ -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"}
|
||||
35
server/dist/sources/core/metrics.js
vendored
Normal file
35
server/dist/sources/core/metrics.js
vendored
Normal file
@@ -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
|
||||
1
server/dist/sources/core/metrics.js.map
vendored
Normal file
1
server/dist/sources/core/metrics.js.map
vendored
Normal file
@@ -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"}
|
||||
43
server/dist/sources/core/playlist.js
vendored
Normal file
43
server/dist/sources/core/playlist.js
vendored
Normal file
@@ -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
|
||||
1
server/dist/sources/core/playlist.js.map
vendored
Normal file
1
server/dist/sources/core/playlist.js.map
vendored
Normal file
@@ -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"}
|
||||
154
server/dist/sources/core/proxyHandler.js
vendored
Normal file
154
server/dist/sources/core/proxyHandler.js
vendored
Normal file
@@ -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/<source>/<enc entry-or-stream URL>
|
||||
// · entry URL (dlhd watch.php / stream-N.php) → adapter.resolveStream() → fresh master, then proxy
|
||||
// · master/variant .m3u8 → rewrite child URIs back through /api/v1/<source>/…
|
||||
// · 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
|
||||
1
server/dist/sources/core/proxyHandler.js.map
vendored
Normal file
1
server/dist/sources/core/proxyHandler.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
16
server/dist/sources/paths.js
vendored
Normal file
16
server/dist/sources/paths.js
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
// This module lives at <root>/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 <id>.source.json baselines + <id>.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
|
||||
1
server/dist/sources/paths.js.map
vendored
Normal file
1
server/dist/sources/paths.js.map
vendored
Normal file
@@ -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"}
|
||||
10
server/dist/sources/registry.js
vendored
Normal file
10
server/dist/sources/registry.js
vendored
Normal file
@@ -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
|
||||
1
server/dist/sources/registry.js.map
vendored
Normal file
1
server/dist/sources/registry.js.map
vendored
Normal file
@@ -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"}
|
||||
137
server/dist/sources/seed.js
vendored
Normal file
137
server/dist/sources/seed.js
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
// Seed / init / sync / reset for the established (Default) source playlists.
|
||||
//
|
||||
// "Both: bundle + live sync" — the committed <id>.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
|
||||
1
server/dist/sources/seed.js.map
vendored
Normal file
1
server/dist/sources/seed.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
46
server/dist/sources/translate.js
vendored
Normal file
46
server/dist/sources/translate.js
vendored
Normal file
@@ -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
|
||||
1
server/dist/sources/translate.js.map
vendored
Normal file
1
server/dist/sources/translate.js.map
vendored
Normal file
@@ -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"}
|
||||
6
server/dist/sources/types.js
vendored
Normal file
6
server/dist/sources/types.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
// The source-adapter contract, ported from d-combine (sources/<id>/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
|
||||
1
server/dist/sources/types.js.map
vendored
Normal file
1
server/dist/sources/types.js.map
vendored
Normal file
@@ -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"}
|
||||
Reference in New Issue
Block a user