Files
tvapp2/server/dist/sources/adapters/dulo.js
2026-06-11 16:40:21 -04:00

116 lines
4.3 KiB
JavaScript

// 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