mirror of
https://github.com/TheBinaryNinja/tvapp2.git
synced 2026-06-12 13:05:41 -04:00
initial push from external dev branches
This commit is contained in:
55
server/src/config.ts
Normal file
55
server/src/config.ts
Normal 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
27
server/src/db.ts
Normal 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
86
server/src/index.ts
Normal 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);
|
||||
});
|
||||
29
server/src/models/ActiveStream.ts
Normal file
29
server/src/models/ActiveStream.ts
Normal 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);
|
||||
13
server/src/models/Activity.ts
Normal file
13
server/src/models/Activity.ts
Normal 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);
|
||||
22
server/src/models/Channel.ts
Normal file
22
server/src/models/Channel.ts
Normal 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);
|
||||
14
server/src/models/CustomPlaylist.ts
Normal file
14
server/src/models/CustomPlaylist.ts
Normal 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);
|
||||
19
server/src/models/EpgSource.ts
Normal file
19
server/src/models/EpgSource.ts
Normal 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);
|
||||
22
server/src/models/Playlist.ts
Normal file
22
server/src/models/Playlist.ts
Normal 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);
|
||||
15
server/src/models/PlaylistChannel.ts
Normal file
15
server/src/models/PlaylistChannel.ts
Normal 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);
|
||||
16
server/src/models/Program.ts
Normal file
16
server/src/models/Program.ts
Normal 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);
|
||||
49
server/src/models/SourceChannel.ts
Normal file
49
server/src/models/SourceChannel.ts
Normal 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);
|
||||
15
server/src/models/StreamSession.ts
Normal file
15
server/src/models/StreamSession.ts
Normal 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);
|
||||
13
server/src/routes/activeStreams.ts
Normal file
13
server/src/routes/activeStreams.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
13
server/src/routes/activity.ts
Normal file
13
server/src/routes/activity.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
22
server/src/routes/channels.ts
Normal file
22
server/src/routes/channels.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
13
server/src/routes/customPlaylists.ts
Normal file
13
server/src/routes/customPlaylists.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
13
server/src/routes/epgSources.ts
Normal file
13
server/src/routes/epgSources.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
8
server/src/routes/health.ts
Normal file
8
server/src/routes/health.ts
Normal 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' });
|
||||
});
|
||||
113
server/src/routes/playlists.ts
Normal file
113
server/src/routes/playlists.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
19
server/src/routes/programs.ts
Normal file
19
server/src/routes/programs.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
91
server/src/routes/sources.ts
Normal file
91
server/src/routes/sources.ts
Normal 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);
|
||||
});
|
||||
13
server/src/routes/streamSessions.ts
Normal file
13
server/src/routes/streamSessions.ts
Normal 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
176
server/src/seed.ts
Normal 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);
|
||||
});
|
||||
118
server/src/sources/adapters/dulo.ts
Normal file
118
server/src/sources/adapters/dulo.ts
Normal 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;
|
||||
54
server/src/sources/core/buildSource.ts
Normal file
54
server/src/sources/core/buildSource.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
18
server/src/sources/core/logger.ts
Normal file
18
server/src/sources/core/logger.ts
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.
|
||||
|
||||
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),
|
||||
};
|
||||
52
server/src/sources/core/metrics.ts
Normal file
52
server/src/sources/core/metrics.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
45
server/src/sources/core/playlist.ts
Normal file
45
server/src/sources/core/playlist.ts
Normal 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');
|
||||
}
|
||||
162
server/src/sources/core/proxyHandler.ts
Normal file
162
server/src/sources/core/proxyHandler.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
19
server/src/sources/paths.ts
Normal file
19
server/src/sources/paths.ts
Normal 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`);
|
||||
}
|
||||
13
server/src/sources/registry.ts
Normal file
13
server/src/sources/registry.ts
Normal 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
161
server/src/sources/seed.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
69
server/src/sources/translate.ts
Normal file
69
server/src/sources/translate.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
55
server/src/sources/types.ts
Normal file
55
server/src/sources/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user