Packaging v0: Build and run server in a docker container (#139)

* This is a nightmare

* Checkpointing.... getting closer

* First cut - packaging the server in a docker container

* Remove busted bundles

* Minify build

* Some common commands for building - we're going to look into proper monorepo solutions soon

* Remove dependency on serve-static

* Add web serving, full-stack docker target, and Nvidia container support

* Remove test action graph for now
This commit is contained in:
Christian Benincasa
2024-03-05 13:13:26 -05:00
committed by GitHub
parent 1eb7091169
commit 5570631adc
157 changed files with 1591 additions and 536 deletions

View File

@@ -1,10 +1,13 @@
node_modules
npm-debug.log
Dockerfile
*Dockerfile
.dockerignore
.git
.gitignore
bin
dist
*/*/dist*
*/*/build*
.pseudotv
.dizquetv
.dizquetv
.tunarr

69
build.Dockerfile Normal file
View File

@@ -0,0 +1,69 @@
FROM node:20-alpine3.19 AS base
# Update
RUN apk add --no-cache libc6-compat
RUN apk update
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base as sources
WORKDIR /tunarr
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY server/ ./server
COPY shared/ ./shared
COPY types ./types
COPY web2 ./web2
FROM sources AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
### Begin server build ###
FROM sources AS build-server
# Install deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
# Build common modules
RUN pnpm run --filter=types --filter=shared build
# Runs tsc --noEmit on the server to ensure the code builds
RUN pnpm run --filter=server typecheck
# Build ORM metadata cache using source files
RUN cd server && pnpm mikro-orm-esm cache:generate --combined --ts
# Replace the non-cached metadata config with the cache
RUN mv server/mikro-orm.prod.config.ts server/mikro-orm.config.ts
# Build and bundle the server
RUN pnpm run --filter=server bundle
### End server build ###
### Begin server web ###
FROM sources AS build-web
# Install deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
# Build common modules
RUN pnpm run --filter=types --filter=shared build
RUN pnpm run --filter=web build
### Experimental: Build a SEA
FROM build-server AS build-exec
COPY --from=build-server /tunarr/server/node_modules /tunarr/server/node_modules
COPY --from=build-server /tunarr/server/build /tunarr/server/build
RUN pnpm run --filter=server make-exec
###
### Begin server run ###
FROM base AS server
COPY --from=prod-deps /tunarr/node_modules /tunarr/node_modules
COPY --from=prod-deps /tunarr/server/node_modules /tunarr/server/node_modules
COPY --from=build-server /tunarr/types /tunarr/types
COPY --from=build-server /tunarr/shared /tunarr/shared
COPY --from=build-server /tunarr/server/package.json /tunarr/server/package.json
COPY --from=build-server /tunarr/server/build /tunarr/server/build
ENV TUNARR_BIND_ADDR=0.0.0.0
EXPOSE 8000
CMD [ "/tunarr/server/build/bundle.js" ]
### Begin server run
### Full stack ###
FROM server AS full-stack
COPY --from=build-web /tunarr/web2/dist /tunarr/server/build/web

77
nvidia.Dockerfile Normal file
View File

@@ -0,0 +1,77 @@
# Setup a node + ffmpeg + nvidia base
FROM jrottenberg/ffmpeg:4.4.4-nvidia2204 AS ffmpeg-base
ENV NODE_MAJOR=20
# Install musl for native node bindings (sqlite)
RUN apt-get install -y musl-dev
RUN ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1
# Install node
RUN <<EOF
apt-get update && apt-get install -y ca-certificates curl gnupg
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
apt-get update && apt-get install nodejs -y
EOF
# Install pnpm
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
EXPOSE 8000
RUN ln -s /usr/local/bin/ffmpeg /usr/bin/ffmpeg
ENTRYPOINT [ "node" ]
# Add Tunarr sources
FROM ffmpeg-base as sources
WORKDIR /tunarr
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY server/ ./server
COPY shared/ ./shared
COPY types ./types
COPY web2 ./web2
FROM sources AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
### Begin server build ###
FROM sources AS build-server
# Install deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
# Build common modules
RUN pnpm run --filter=types --filter=shared build
# Runs tsc --noEmit on the server to ensure the code builds
RUN pnpm run --filter=server typecheck
# Build ORM metadata cache using source files
RUN cd server && pnpm mikro-orm-esm cache:generate --combined --ts
# Replace the non-cached metadata config with the cache
RUN mv server/mikro-orm.prod.config.ts server/mikro-orm.config.ts
# Build and bundle the server
RUN pnpm run --filter=server bundle
### End server build ###
### Begin server web ###
FROM sources AS build-web
# Install deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
# Build common modules
RUN pnpm run --filter=types --filter=shared build
RUN pnpm run --filter=web build
### Begin server run ###
FROM ffmpeg-base AS server
COPY --from=prod-deps /tunarr/node_modules /tunarr/node_modules
COPY --from=prod-deps /tunarr/server/node_modules /tunarr/server/node_modules
COPY --from=build-server /tunarr/types /tunarr/types
COPY --from=build-server /tunarr/shared /tunarr/shared
COPY --from=build-server /tunarr/server/package.json /tunarr/server/package.json
COPY --from=build-server /tunarr/server/build /tunarr/server/build
ENV TUNARR_BIND_ADDR=0.0.0.0
EXPOSE 8000
CMD [ "/tunarr/server/build/bundle.js" ]
### Begin server run
### Full stack ###
FROM server AS full-stack
COPY --from=build-web /tunarr/web2/dist /tunarr/server/build/web

766
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
packages:
- "server/"
- "web2/"
- "types/"
- "shared/"
- server
- web2
- types
- shared

View File

@@ -1 +0,0 @@
{"items":[{"type":"content","id":"50b50157-3f03-4ca4-870c-5589c8e35993","durationMs":5526279},{"type":"content","id":"d7e04cc1-94fc-4b53-9cae-247937353851","durationMs":6308448},{"type":"content","id":"32084337-f1db-4459-a558-1b2845eb78df","durationMs":7436480},{"type":"content","id":"c1f9bed1-d3b1-4abc-bec4-d51a0587c0a8","durationMs":5053010},{"type":"content","id":"5020e186-f461-43cc-bbc1-345a085c1d05","durationMs":5776799}]}

7
server/cjs-shim.ts Normal file
View File

@@ -0,0 +1,7 @@
import { createRequire } from 'node:module';
import path from 'node:path';
import url from 'node:url';
globalThis.require = createRequire(import.meta.url);
globalThis.__filename = url.fileURLToPath(import.meta.url);
globalThis.__dirname = path.dirname(__filename);

View File

@@ -0,0 +1,57 @@
import path from 'path';
import { Plugin } from 'esbuild';
// Copied from https://github.com/evanw/esbuild/issues/1051#issuecomment-806325487
export const nativeNodeModulesPlugin = (): Plugin => {
return {
name: 'native-node-modules',
setup(build) {
// If a ".node" file is imported within a module in the "file" namespace, resolve
// it to an absolute path and put it into the "node-file" virtual namespace.
build.onResolve({ filter: /\.node$/, namespace: 'file' }, (args) => {
const resolvedId = require.resolve(args.path, {
paths: [args.resolveDir],
});
if (resolvedId.endsWith('.node')) {
return {
path: resolvedId,
namespace: 'node-file',
};
}
return {
path: resolvedId,
};
});
// Files in the "node-file" virtual namespace call "require()" on the
// path from esbuild of the ".node" file in the output directory.
build.onLoad({ filter: /.*/, namespace: 'node-file' }, (args) => {
return {
contents: `
import path from ${JSON.stringify(args.path)}
try { module.exports = require(path) }
catch {}
`,
resolveDir: path.dirname(args.path),
};
});
// If a ".node" file is imported within a module in the "node-file" namespace, put
// it in the "file" namespace where esbuild's default loading behavior will handle
// it. It is already an absolute path since we resolved it to one above.
build.onResolve(
{ filter: /\.node$/, namespace: 'node-file' },
(args) => ({
path: args.path,
namespace: 'file',
}),
);
// Tell esbuild's default loading behavior to use the "file" loader for
// these ".node" files.
const opts = build.initialOptions;
opts.loader = opts.loader || {};
opts.loader['.node'] = 'file';
},
};
};

View File

@@ -0,0 +1,24 @@
import { Plugin } from 'esbuild';
/**
* The node: protocol was added to require in Node v14.18.0
* https://nodejs.org/api/esm.html#node-imports
*/
export const nodeProtocolPlugin = (): Plugin => {
const nodeProtocol = 'node:';
return {
name: 'node-protocol-plugin',
setup({ onResolve }) {
onResolve(
{
filter: /^node:/,
},
({ path }) => ({
path: path.slice(nodeProtocol.length),
external: true,
}),
);
},
};
};

View File

@@ -0,0 +1,46 @@
import {
UnderscoreNamingStrategy,
defineConfig,
} from '@mikro-orm/better-sqlite';
import { Migrator } from '@mikro-orm/migrations';
import { fileURLToPath } from 'node:url';
import path, { dirname } from 'path';
import { CachedImage } from './src/dao/entities/CachedImage.js';
import { Channel } from './src/dao/entities/Channel.js';
import { ChannelFillerShow } from './src/dao/entities/ChannelFillerShow.js';
import { CustomShow } from './src/dao/entities/CustomShow.js';
import { CustomShowContent } from './src/dao/entities/CustomShowContent.js';
import { FillerListContent } from './src/dao/entities/FillerListContent.js';
import { FillerShow } from './src/dao/entities/FillerShow.js';
import { PlexServerSettings } from './src/dao/entities/PlexServerSettings.js';
import { Program } from './src/dao/entities/Program.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = path.join(process.env.DB_PATH ?? '.dizquetv', 'db.db');
export default defineConfig({
dbName: dbPath,
baseDir: __dirname,
entities: [
CachedImage,
Channel,
ChannelFillerShow,
CustomShow,
CustomShowContent,
FillerListContent,
FillerShow,
PlexServerSettings,
Program,
],
debug: !!process.env['DATABASE_DEBUG_LOGGING'],
namingStrategy: UnderscoreNamingStrategy,
forceUndefined: true,
dynamicImportProvider: (id) => import(id),
migrations: {
path: './migrations',
pathTs: './src/migrations',
},
extensions: [Migrator],
});

View File

@@ -1,30 +1,10 @@
import {
UnderscoreNamingStrategy,
defineConfig,
} from '@mikro-orm/better-sqlite';
import { Migrator } from '@mikro-orm/migrations';
import { defineConfig } from '@mikro-orm/better-sqlite';
import { TsMorphMetadataProvider } from '@mikro-orm/reflection';
import { fileURLToPath } from 'node:url';
import path, { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = path.join(process.env.DB_PATH ?? '.dizquetv', 'db.db');
import baseConfig from './mikro-orm.base.config.js';
export default defineConfig({
dbName: dbPath,
baseDir: __dirname,
entities: ['./build/dao/entities'], // path to our JS entities (dist), relative to `baseDir`
entitiesTs: ['./dao/entities'], // path to our TS entities (src), relative to `baseDir`
debug: !!process.env['DATABASE_DEBUG_LOGGING'],
namingStrategy: UnderscoreNamingStrategy,
forceUndefined: true,
dynamicImportProvider: (id) => import(id),
...baseConfig,
entities: ['./dao/entities'], // path to our JS entities (dist), relative to `baseDir`
entitiesTs: ['./src/dao/entities'], // path to our TS entities (src), relative to `baseDir`
metadataProvider: TsMorphMetadataProvider,
migrations: {
path: './build/migrations',
pathTs: './migrations',
},
extensions: [Migrator],
});

View File

@@ -0,0 +1,14 @@
import { GeneratedCacheAdapter, defineConfig } from '@mikro-orm/better-sqlite';
import baseConfig from './mikro-orm.base.config.js';
import metadataJson from './temp/metadata.json' assert { type: 'json' };
export default defineConfig({
...baseConfig,
metadataCache: {
enabled: true,
adapter: GeneratedCacheAdapter,
options: {
data: metadataJson,
},
},
});

View File

@@ -5,17 +5,18 @@
"license": "Zlib",
"author": "chrisbenincasa",
"type": "module",
"main": "index.ts",
"main": "src/index.ts",
"bin": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"buildw": "nodemon -e ts --watch . --watch index.ts --ignore build/ -e .json,.ts,.yml,.yaml,.js -x tsc --project tsconfig.build.json",
"typecheck": "tsc -p tsconfig.build.json --noEmit",
"bundle": "node --loader ts-node/esm scripts/bundle.ts",
"clean": "rimraf build",
"debug": "nodemon --ignore build/ -e .json,.ts,.yml,.yaml,.js --exec \"node --inspect-brk=0.0.0.0:4321 --loader ts-node/esm ./index.ts\"",
"dev": "nodemon -e ts --watch . --watch index.ts --ignore build/ --ignore streams/ -e .json,.ts,.yml,.yaml,.js -x ts-node-esm -r tsconfig-paths/register --files --project tsconfig.build.json index.ts --inspect=4321",
"dizquetv": "ts-node-esm index.ts",
"preinstall": "npx only-allow pnpm",
"dev": "nodemon -e ts --watch src/ --ignore build/ --ignore streams/ -e .json,.ts,.yml,.yaml,.js -x ts-node-esm -r tsconfig-paths/register --files --project tsconfig.build.json src/index.ts --inspect=4321",
"make-exec": "node --loader ts-node/esm scripts/makeExecutable.ts",
"mikro-orm": "NODE_OPTIONS='--loader ts-node/esm --no-warnings' mikro-orm",
"package": "sh ./make_dist.sh",
"preinstall": "npx only-allow pnpm",
"test": "vitest"
},
"dependencies": {
@@ -27,7 +28,6 @@
"@mikro-orm/better-sqlite": "^6.0.4",
"@mikro-orm/core": "^6.0.4",
"@mikro-orm/migrations": "6.0.4",
"@mikro-orm/reflection": "^6.0.4",
"@tunarr/shared": "workspace:*",
"@tunarr/types": "workspace:*",
"JSONStream": "1.0.5",
@@ -46,6 +46,7 @@
"fastify-print-routes": "^2.2.0",
"fastify-type-provider-zod": "^1.1.9",
"fluent-ffmpeg": "^2.1.2",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"lowdb": "^7.0.0",
"morgan": "^1.10.0",
@@ -57,7 +58,6 @@
"random-js": "2.1.0",
"reflect-metadata": "^0.1.13",
"retry": "^0.13.1",
"serve-static": "^1.15.0",
"tslib": "^2.6.2",
"uuid": "^9.0.1",
"winston": "^3.11.0",
@@ -68,11 +68,13 @@
},
"devDependencies": {
"@mikro-orm/cli": "^6.0.4",
"@mikro-orm/reflection": "^6.0.4",
"@types/async-retry": "^1.4.8",
"@types/better-sqlite3": "^7.6.8",
"@types/express": "^4.17.20",
"@types/express-fileupload": "^1.4.3",
"@types/fluent-ffmpeg": "^2.1.23",
"@types/lodash": "^4.14.202",
"@types/lodash-es": "^4.17.10",
"@types/morgan": "^1.9.7",
"@types/node": "^20.8.9",
@@ -84,17 +86,23 @@
"@types/yargs": "^17.0.29",
"copyfiles": "^2.2.0",
"del-cli": "^3.0.0",
"nexe": "^4.0.0-rc.2",
"esbuild": "^0.19.5",
"fast-glob": "^3.3.2",
"nexe": "4.0.0-rc.4",
"nodemon": "^3.0.3",
"pkg": "^5.8.1",
"postject": "1.0.0-alpha.6",
"prettier": "^3.0.3",
"rimraf": "^5.0.5",
"tmp": "^0.2.1",
"tmp-promise": "^3.0.3",
"ts-essentials": "^9.4.1",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"tsify": "^5.0.4",
"tsup": "^8.0.2",
"typed-emitter": "^2.1.0",
"typescript": "^5.2.2",
"typescript": "^5.3.3",
"vitest": "^1.2.0"
},
"mikro-orm": {

62
server/scripts/bundle.ts Normal file
View File

@@ -0,0 +1,62 @@
import esbuild from 'esbuild';
import fg from 'fast-glob';
import fs from 'node:fs';
import { rimraf } from 'rimraf';
import { nativeNodeModulesPlugin } from '../esbuild/native-node-module.js';
import { nodeProtocolPlugin } from '../esbuild/node-protocol.js';
if (fs.existsSync('build')) {
console.log('Deleting old build...');
await rimraf('build');
}
fs.mkdirSync('build');
console.log('Copying images...');
fs.cpSync('src/resources/images', 'build/resources/images', {
recursive: true,
});
console.log('Bundling app...');
await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
// minify: true,
// We can't make this mjs yet because mikro-orm breaks
// when using cached metadata w/ not js/ts suffixes:
// https://github.com/mikro-orm/mikro-orm/blob/e005cc22ef4e247f9741bdcaf1af012337977b7e/packages/core/src/cache/GeneratedCacheAdapter.ts#L16
outfile: 'build/bundle.js',
format: 'esm',
platform: 'node',
target: 'node18',
inject: ['cjs-shim.ts'],
packages: 'external',
tsconfig: './tsconfig.build.json',
logLevel: 'verbose',
// external: [
// 'mysql',
// 'mysql2',
// 'sqlite3',
// 'pg',
// 'tedious',
// 'pg-query-stream',
// 'oracledb',
// 'assert',
// ],
mainFields: ['module', 'main'],
plugins: [nativeNodeModulesPlugin(), nodeProtocolPlugin()],
});
console.log('Bundling DB migrations...');
await esbuild.build({
entryPoints: await fg('src/migrations/*'),
outdir: 'build/migrations',
bundle: false,
packages: 'external',
tsconfig: './tsconfig.build.json',
});
fs.cpSync(
'src/migrations/.snapshot-db.db.json',
'build/migrations/.snapshot-db.db.json',
);

View File

@@ -0,0 +1,10 @@
import { compile } from 'nexe';
await compile({
input: './build/bundle.js',
output: './build/server-macos',
targets: ['x64-20.11.1'],
build: true,
resources: ['./build/**/*', 'node_modules/**/*'],
python: 'python3',
});

View File

@@ -0,0 +1,5 @@
{
"scripts": "build/bundle.js",
"targets": ["node18-linux-arm64"],
"outputPath": "dist"
}

5
server/scripts/sea.json Normal file
View File

@@ -0,0 +1,5 @@
{
"main": "build/bundle.js",
"output": "build/blob.blob",
"disableExperimentalSEAWarning": true
}

View File

@@ -4,6 +4,7 @@ import { XmlTvSettings, defaultXmlTvSettings } from '../dao/settings.js';
import createLogger from '../logger.js';
import { scheduledJobsById } from '../services/scheduler.js';
import { firstDefined } from '../util.js';
import { serverOptions } from '../globals.js';
const logger = createLogger(import.meta);
@@ -70,7 +71,7 @@ export const xmlTvSettingsRouter: FastifyPluginCallback = (
try {
await req.serverCtx.settings.updateSettings(
'xmltv',
defaultXmlTvSettings,
defaultXmlTvSettings(serverOptions().database),
);
const xmltv = req.serverCtx.settings.xmlTvSettings();
await res.send(xmltv);

View File

@@ -10,7 +10,7 @@ import path from 'node:path';
import 'reflect-metadata';
import { globalOptions } from '../globals.js';
import createLogger from '../logger.js';
import dbConfig from '../mikro-orm.config.js';
import dbConfig from '../../mikro-orm.config.js';
const logger = createLogger(import.meta);

View File

@@ -1,6 +1,6 @@
import { test, describe, beforeAll, afterAll } from 'vitest';
import { MikroORM, RequestContext } from '@mikro-orm/better-sqlite';
import dbConfig from '../mikro-orm.config.js';
import dbConfig from '../../mikro-orm.prod.config.js';
import {
migrateChannel,
migratePrograms,

View File

@@ -141,7 +141,7 @@ async function migrateFromLegacyDbInner(
) {
const entitiesToMigrate = entities ?? MigratableEntities;
// First initialize the default schema:
db.data = { ...defaultSchema };
db.data = { ...defaultSchema(globalOptions().database) };
await db.write();
let settings: Partial<SettingsSchema> = {};
@@ -180,7 +180,7 @@ async function migrateFromLegacyDbInner(
settings = {
...settings,
xmltv: {
...defaultXmlTvSettings,
...defaultXmlTvSettings(globalOptions().database),
},
};
} else {

View File

@@ -11,7 +11,6 @@ import { JSONFilePreset } from 'lowdb/node';
import path from 'path';
import { DeepReadonly } from 'ts-essentials';
import { v4 as uuidv4 } from 'uuid';
import constants from '../constants.js';
import { globalOptions } from '../globals.js';
const CURRENT_VERSION = 1;
@@ -55,12 +54,12 @@ export type XmlTvSettings = {
enableImageCache: boolean;
};
export const defaultXmlTvSettings: XmlTvSettings = {
export const defaultXmlTvSettings = (dbBasePath: string): XmlTvSettings => ({
programmingHours: 12,
refreshHours: 4,
outputPath: path.resolve(constants.DEFAULT_DATA_DIR, 'xmltv.xml'),
outputPath: path.resolve(dbBasePath, 'xmltv.xml'),
enableImageCache: false,
};
});
export type SettingsSchema = {
clientId: string;
@@ -86,7 +85,7 @@ export type Schema = {
settings: SettingsSchema;
};
export const defaultSchema: Schema = {
export const defaultSchema = (dbBasePath: string): Schema => ({
version: 1,
migration: {
legacyMigration: false,
@@ -94,11 +93,11 @@ export const defaultSchema: Schema = {
settings: {
clientId: uuidv4(),
hdhr: defaultHdhrSettings,
xmltv: defaultXmlTvSettings,
xmltv: defaultXmlTvSettings(dbBasePath),
plexStream: defaultPlexStreamSettings,
ffmpeg: defaultFfmpegSettings,
},
};
});
export class Settings {
private db: Low<Schema>;
@@ -150,7 +149,10 @@ export const getSettingsRawDb = once(async (dbPath?: string) => {
const needsFlush = !existsSync(actualPath);
const db = await JSONFilePreset<Schema>(actualPath, defaultSchema);
const db = await JSONFilePreset<Schema>(
actualPath,
defaultSchema(globalOptions().database),
);
await db.read();
if (needsFlush) {

View File

@@ -16,11 +16,22 @@ import createLogger from './logger.js';
import { ServerOptions } from './types.js';
import { existsSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { initServer } from './server.js';
const logger = createLogger(import.meta);
const maybeEnvPort = () => {
const port = process.env['TUNARR_SERVER_PORT'];
if (!port) {
return;
}
const parsed = parseInt(port);
return isNaN(parsed) ? undefined : parsed;
};
yargs(hideBin(process.argv))
.scriptName('dizquetv')
.scriptName('tunarr')
.option('database', {
alias: 'd',
type: 'string',
@@ -31,7 +42,7 @@ yargs(hideBin(process.argv))
})
.option('force_migration', {
type: 'boolean',
desc: 'Forces a migration from a legacy dizque database. Useful for development and debugging. NOTE: This WILL override any settings you have!',
desc: 'Forces a migration from a legacy dizquetv database. Useful for development and debugging. NOTE: This WILL override any settings you have!',
default: false,
})
.middleware(setGlobalOptions)
@@ -40,19 +51,33 @@ yargs(hideBin(process.argv))
})
.command(
['server', '$0'],
'Run the dizqueTV server',
'Run the Tunarr server',
(yargs) => {
return yargs
.option('port', {
alias: 'p',
type: 'number',
desc: 'The port to run the dizque server on',
default: 8000,
desc: 'The port to run the Tunarr server on',
default: maybeEnvPort() ?? 8000,
})
.option('printRoutes', {
type: 'boolean',
default: false,
})
.middleware(setServerOptions);
},
async (args: ArgumentsCamelCase<ServerOptions>) => {
(await import('./server.js')).initServer(args);
console.log(
` \\
Tunarr ${constants.VERSION_NAME}
.------------.
|:::///### o |
|:::///### |
':::///### o |
'------------'
`,
);
await initServer(args);
},
)
.command(
@@ -63,13 +88,17 @@ yargs(hideBin(process.argv))
.option('port', {
alias: 'p',
type: 'number',
desc: 'The port to run the dizque server on',
default: 8000,
desc: 'The port to run the Tunarr server on',
default: maybeEnvPort() ?? 8000,
})
.option('printRoutes', {
type: 'boolean',
default: false,
})
.middleware(setServerOptions);
},
async (args: ArgumentsCamelCase<ServerOptions>) => {
const f = await (await import('./server.js')).initServer(args);
const f = await initServer(args);
const x = await f
.inject({ method: 'get', url: '/docs/json' })
.then((r) => r.body);

View File

@@ -6,6 +6,7 @@ import { isUndefined, join } from 'lodash-es';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import chalk from 'chalk';
import { isProduction } from './util.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -15,27 +16,32 @@ const getLabel = (callingModule: ImportMeta) => {
return path.join(parts[parts.length - 2], parts.pop() ?? '');
};
const hformat = (module: ImportMeta) =>
winston.format.printf(({ level, label, message, timestamp, ...metadata }) => {
let msg = `${timestamp} [${level}] ${getLabel(module)}${
label ? `[${label}]` : ''
}: ${message} `;
for (const key of Object.keys(metadata)) {
if (key === 'stack') {
msg += metadata.message;
if (metadata.stack) {
msg += '\n';
msg += join(
metadata.stack.split('\n').map((line) => '\t' + line),
'\n',
);
const hformat = (module: ImportMeta) => {
// Exclude module label in prod build because it will always be the same (bundle.js)
const moduleLabel = isProduction ? '' : ` ${getLabel(module)}`;
return winston.format.printf(
({ level, label, message, timestamp, ...metadata }) => {
let msg = `${timestamp} [${level}]${moduleLabel}${
label ? `[${label}]` : ''
}: ${message} `;
for (const key of Object.keys(metadata)) {
if (key === 'stack') {
msg += metadata.message;
if (metadata.stack) {
msg += '\n';
msg += join(
metadata.stack.split('\n').map((line) => '\t' + line),
'\n',
);
}
} else if (isUndefined(metadata)) {
msg += chalk.gray('undefined');
}
} else if (isUndefined(metadata)) {
msg += chalk.gray('undefined');
}
}
return msg;
});
return msg;
},
);
};
const createLogger = (module: ImportMeta) => {
const logger = winston.createLogger({

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,136 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="200"
height="200"
viewBox="0 0 52.9168 52.916668"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="favicon.svg"
inkscape:export-filename="/home/vx/dev/pseudotv/resources/favicon-16.png"
inkscape:export-xdpi="7.6799998"
inkscape:export-ydpi="7.6799998">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="3.0547013"
inkscape:cx="55.816079"
inkscape:cy="84.726326"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1920"
inkscape:window-height="1056"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Capa 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-244.08278)">
<rect
style="opacity:1;fill:#a1a1a1;fill-opacity:0.86666667;stroke:none;stroke-width:1.46508551;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4712"
width="52.211964"
height="51.512306"
x="-0.85796964"
y="245.32475"
transform="matrix(0.99980416,-0.01978974,0.00448328,0.99998995,0,0)" />
<g
id="g4581"
style="fill:#080808;fill-opacity:1;stroke-width:0.68901283"
transform="matrix(1.2119871,0,0,1.7379906,-82.577875,-167.18505)">
<rect
transform="rotate(-0.94645665)"
y="239.28041"
x="65.156158"
height="27.75024"
width="41.471352"
id="rect4524"
style="opacity:1;fill:#080808;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
</g>
<rect
style="opacity:1;fill:#9cbc28;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4518"
width="10.338528"
height="23.042738"
x="8.726779"
y="258.22861"
transform="matrix(0.99995865,0.00909414,-0.00926779,0.99995705,0,0)" />
<ellipse
style="opacity:1;fill:#a1a1a1;fill-opacity:0.86792453;stroke:none;stroke-width:1.46499991;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path4568"
cx="44.118061"
cy="261.20392"
rx="2.4216392"
ry="2.3988426" />
<ellipse
cy="272.90894"
cx="44.765343"
id="circle4570"
style="opacity:1;fill:#a1a1a1;fill-opacity:0.86792453;stroke:none;stroke-width:1.46499991;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
rx="2.4216392"
ry="2.3988426" />
<g
id="g1705"
transform="translate(0,-2.116672)">
<rect
transform="matrix(0.99967585,0.02545985,-0.02594573,0.99966335,0,0)"
style="opacity:1;fill:#289bbc;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4520"
width="10.338468"
height="23.042864"
x="23.424755"
y="259.99872" />
</g>
<rect
transform="matrix(0.99837418,-0.05699994,0.05808481,0.99831165,0,0)"
y="259.69229"
x="10.517879"
height="23.043449"
width="10.33821"
id="rect4522"
style="opacity:1;fill:#bc289b;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:2.03992438px;line-height:125%;font-family:'Liberation Serif';-inkscape-font-specification:'Liberation Serif';letter-spacing:0px;word-spacing:0px;fill:#e6e6e6;fill-opacity:1;stroke:none;stroke-width:0.264584px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
x="16.979799"
y="286.34747"
id="text1730"
transform="rotate(-1.2296789)"><tspan
sodipodi:role="line"
id="tspan1728"
x="16.979799"
y="286.34747"
style="fill:#e6e6e6;fill-opacity:1;stroke-width:0.264584px">dizqueTV</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -17,11 +17,7 @@ import {
import fs from 'fs';
import morgan from 'morgan';
import { onShutdown } from 'node-graceful-shutdown';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import path from 'path';
import serveStatic from 'serve-static';
import { URL } from 'url';
import path, { dirname } from 'path';
import { miscRouter } from './api.js';
import { ffmpegSettingsRouter } from './api/ffmpegSettingsApi.js';
import { guideRouter } from './api/guideApi.js';
@@ -32,7 +28,6 @@ import { schedulerRouter } from './api/schedulerApi.js';
import { debugApi } from './api/v2/debugApi.js';
import registerV2Routes from './api/v2/index.js';
import { xmlTvSettingsRouter } from './api/xmltvSettingsApi.js';
import constants from './constants.js';
import { EntityManager, initOrm } from './dao/dataSource.js';
import { migrateFromLegacyDb } from './dao/legacyDbMigration.js';
import { getSettingsRawDb } from './dao/settings.js';
@@ -40,36 +35,14 @@ import { serverOptions } from './globals.js';
import createLogger from './logger.js';
import { serverContext } from './serverContext.js';
import { scheduleJobs, scheduledJobsById } from './services/scheduler.js';
import { runFixers } from './tasks/fixers/index.js';
import { UpdateXmlTvTask } from './tasks/updateXmlTvTask.js';
import { ServerOptions } from './types.js';
import { wait } from './util.js';
import { filename, wait } from './util.js';
import { videoRouter } from './video.js';
import { runFixers } from './tasks/fixers/index.js';
const logger = createLogger(import.meta);
// Temporary
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(
` \\
Tunarr ${constants.VERSION_NAME}
.------------.
|:::///### o |
|:::///### |
':::///### o |
'------------'
`,
);
const NODE = parseInt(process.version.match(/^[^0-9]*(\d+)\..*$/)![1]);
if (NODE < 12) {
logger.error(
`WARNING: Your nodejs version ${process.version} is lower than supported. dizqueTV has been tested best on nodejs 12.16.`,
);
}
const currentDirectory = dirname(filename(import.meta.url));
function initDbDirectories() {
const opts = serverOptions();
@@ -106,6 +79,27 @@ function initDbDirectories() {
}
export async function initServer(opts: ServerOptions) {
onShutdown('log', [], async () => {
const ctx = await serverContext();
const t = new Date().getTime();
ctx.eventService.push({
type: 'lifecycle',
message: `Initiated Server Shutdown`,
detail: {
time: t,
},
level: 'warning',
});
logger.info('Received exit signal, attempting graceful shutdonw...');
await wait(2000);
});
onShutdown('xmltv-writer', [], async () => {
const ctx = await serverContext();
await ctx.xmltv.shutdown();
});
const hadLegacyDb = initDbDirectories();
const orm = await initOrm();
@@ -122,11 +116,12 @@ export async function initServer(opts: ServerOptions) {
const updateXMLPromise = scheduledJobsById[UpdateXmlTvTask.ID]!.runNow();
const app = fastify({ logger: false, bodyLimit: 50 * 1024 * 1024 });
await app
const app = fastify({ logger: false, bodyLimit: 50 * 1024 * 1024 })
.setValidatorCompiler(validatorCompiler)
.setSerializerCompiler(serializerCompiler)
.withTypeProvider<ZodTypeProvider>()
.withTypeProvider<ZodTypeProvider>();
await app
.register(fastifySwagger, {
openapi: {
info: {
@@ -149,7 +144,6 @@ export async function initServer(opts: ServerOptions) {
RequestContext.create(orm.em, done),
)
.addHook('onClose', async () => await orm.close())
.register(fastifyPrintRoutes)
.register(
fp((f, _, done) => {
f.decorateRequest('serverCtx', null);
@@ -162,7 +156,11 @@ export async function initServer(opts: ServerOptions) {
}),
);
await app.use(
if (serverOptions().printRoutes) {
await app.register(fastifyPrintRoutes);
}
app.use(
morgan(':method :url :status :res[content-length] - :response-time ms', {
stream: {
write: (message) => logger.http(message.trim()),
@@ -175,20 +173,28 @@ export async function initServer(opts: ServerOptions) {
ctx.eventService.setup(app);
await app
.use(serveStatic(fileURLToPath(new URL('../web/public', import.meta.url))))
.use('/images', serveStatic(path.join(opts.database, 'images')))
.use(
'/favicon.svg',
serveStatic(path.join(__dirname, 'resources', 'favicon.svg')),
)
.use('/custom.css', serveStatic(path.join(opts.database, 'custom.css')));
// API Routers
await app
.register(fpStatic, {
root: path.join(currentDirectory, 'resources', 'images'),
prefix: '/images',
})
.get('/favicon.svg', async (_, res) => {
return res.sendFile(
'favicon.svg',
path.join(currentDirectory, 'resources', 'images'),
);
})
.get('/favicon.ico', async (_, res) => {
return res.sendFile(
'favicon.ico',
path.join(currentDirectory, 'resources', 'images'),
);
})
.register(async (f) => {
await f.register(fpStatic, {
root: path.join(opts.database, 'cache', 'images'),
decorateReply: false,
});
// f.addHook('onRequest', async (req, res) => ctx.cacheImageService.routerInterceptor(req, res));
f.get<{ Params: { hash: string } }>(
@@ -220,16 +226,27 @@ export async function initServer(opts: ServerOptions) {
prefix: '/api/cache/images',
})
.register(videoRouter)
.register(ctx.hdhrService.createRouter());
.register(ctx.hdhrService.createRouter())
.register(async (f) => {
await f.register(fpStatic, {
root: path.join(currentDirectory, 'web'),
prefix: '/web',
});
f.get('/web', async (_, res) =>
res.sendFile('index.html', path.join(currentDirectory, 'web')),
);
});
await updateXMLPromise;
const host = process.env['TUNARR_BIND_ADDR'] ?? 'localhost';
app.listen(
{
host,
port: opts.port,
},
() => {
logger.info(`HTTP server running on port: http://*:${opts.port}`);
logger.info(`HTTP server running on port: http://${host}:${opts.port}`);
const hdhrSettings = ctx.settings.hdhrSettings();
if (hdhrSettings.autoDiscoveryEnabled) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
@@ -249,24 +266,3 @@ export async function initServer(opts: ServerOptions) {
return app;
}
onShutdown('log', [], async () => {
const ctx = await serverContext();
const t = new Date().getTime();
ctx.eventService.push({
type: 'lifecycle',
message: `Initiated Server Shutdown`,
detail: {
time: t,
},
level: 'warning',
});
logger.info('Received exit signal, attempting graceful shutdonw...');
await wait(2000);
});
onShutdown('xmltv-writer', [], async () => {
const ctx = await serverContext();
await ctx.xmltv.shutdown();
});

View File

@@ -1,12 +1,11 @@
import fs, { promises as fsPromises } from 'fs';
import { once } from 'lodash-es';
import path from 'path';
import { ChannelCache } from './channelCache.js';
import { ChannelDB } from './dao/channelDb.js';
import { CustomShowDB } from './dao/customShowDb.js';
import { Settings, getSettings } from './dao/settings.js';
import { FillerDB } from './dao/fillerDb.js';
import { PlexServerDB } from './dao/plexServerDb.js';
import { Settings, getSettings } from './dao/settings.js';
import { serverOptions } from './globals.js';
import { HdhrService } from './hdhr.js';
import { CacheImageService } from './services/cacheImageService.js';
@@ -16,43 +15,6 @@ import { M3uService } from './services/m3uService.js';
import { TVGuideService } from './services/tvGuideService.js';
import { XmlTvWriter } from './xmltv.js';
async function copyIfMissingFromDatabase(
targetPath: string,
resourcePath: string,
): Promise<void> {
const opts = serverOptions();
if (!fs.existsSync(path.join(opts.database, targetPath))) {
await fsPromises.copyFile(
new URL('../' + resourcePath, import.meta.url),
path.join(opts.database, targetPath),
);
}
}
function initDBDirectory() {
return Promise.all([
copyIfMissingFromDatabase('images/dizquetv.png', 'resources/dizquetv.png'),
copyIfMissingFromDatabase('font.ttf', 'resources/font.ttf'),
copyIfMissingFromDatabase(
'images/generic-error-screen.png',
'resources/generic-error-screen.png',
),
copyIfMissingFromDatabase(
'images/generic-offline-screen.png',
'resources/generic-offline-screen.png',
),
copyIfMissingFromDatabase(
'images/generic-music-screen.png',
'resources/generic-music-screen.png',
),
copyIfMissingFromDatabase(
'images/loading-screen.png',
'resources/loading-screen.png',
),
copyIfMissingFromDatabase('custom.css', 'resources/default-custom.css'),
]);
}
export type ServerContext = {
channelDB: ChannelDB;
fillerDB: FillerDB;
@@ -83,8 +45,6 @@ export const serverContext: () => Promise<ServerContext> = once(async () => {
const eventService = new EventService();
const xmltv = new XmlTvWriter();
await initDBDirectory();
const guideService = new TVGuideService(
xmltv,
cacheImageService,

Some files were not shown because too many files have changed in this diff Show More