initial push from external dev branches

This commit is contained in:
iFlip721
2026-06-11 16:40:21 -04:00
parent 986e83632b
commit 245034a43a
182 changed files with 15465 additions and 0 deletions

55
server/src/config.ts Normal file
View File

@@ -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<string, unknown>;
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 };
}

27
server/src/db.ts Normal file
View File

@@ -0,0 +1,27 @@
import mongoose from 'mongoose';
export async function connect(uri: string): Promise<void> {
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<void> {
await mongoose.disconnect();
}
export function isConnected(): boolean {
return mongoose.connection.readyState === 1;
}

86
server/src/index.ts Normal file
View File

@@ -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);
});

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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` ("<source>:<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; // "<source>:<sourceChannelId>" — 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<SourceChannelDoc>(
{
_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<SourceChannelDoc>('SourceChannel', SourceChannelSchema);

View File

@@ -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);

View File

@@ -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);
}
});

View File

@@ -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);
}
});

View File

@@ -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=<id> → 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);
}
});

View File

@@ -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);
}
});

View File

@@ -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);
}
});

View File

@@ -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' });
});

View File

@@ -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<number> {
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<SourceChannelDoc[]>();
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);
}
});

View File

@@ -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<string, Array<{ start: number; end: number; title: string; cat: string }>> = {};
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);
}
});

View File

@@ -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<string, Metrics>();
const proxyHandlers = new Map<string, RequestHandler>();
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);
});

View File

@@ -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);
}
});

176
server/src/seed.ts Normal file
View File

@@ -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: '<b>IPTV-Pro Main</b> synced — 142 channels, no changes' },
{ when: '12m', icon: 'epg', html: '<b>XMLTV UK Guide</b> imported — 8,420 programs across 124 channels' },
{ when: '1h', icon: 'map', html: 'Manual mapping: <b>HGTV</b> → <code class="mono">hgtv.uk</code>' },
{ when: '1h', icon: 'warn', html: '<b>Free UK Bouquet</b> reports 3 channels offline (HTTP 503)' },
{ when: '3h', icon: 'edit', html: 'Renamed <b>Discovery</b> → <b>Discovery Channel</b>' },
{ when: 'Yest.', icon: 'add', html: 'Playlist <b>IPTV-Pro Main</b> 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);
});

View File

@@ -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;

View File

@@ -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<string, unknown>;
}
export async function buildSource(adapter: SourceAdapter): Promise<BuildResult> {
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<string, SourceChannelDoc>();
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 },
};
}

View 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.
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),
};

View File

@@ -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,
};
}

View File

@@ -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');
}

View File

@@ -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/<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 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<void> {
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<ReturnType<typeof fetch>>;
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<typeof Readable.fromWeb>[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);
};
}

View File

@@ -0,0 +1,19 @@
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: string): string {
return resolve(SEED_SOURCES_DIR, `${sourceId}.source.json`);
}
export function snapshotFile(sourceId: string): string {
return resolve(SEED_SOURCES_DIR, `${sourceId}.snapshot.json`);
}

View File

@@ -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);
}

161
server/src/sources/seed.ts Normal file
View File

@@ -0,0 +1,161 @@
// 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, 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<void> {
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<void> {
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<IntegrityReport> {
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<IntegrityReport> {
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<IntegrityReport> {
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<IntegrityReport> {
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<void> {
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}`);
}
}
}

View File

@@ -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; // "<source>:<sourceChannelId>"
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,
};
}

View File

@@ -0,0 +1,55 @@
// 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.
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<string, string>;
/** 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<RawListing>;
/** 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<unknown>;
/** 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;
}